Skip to content
e280Public

About

🦝 we're doing shadow views now

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

🦝 sly

get in loser, we're doing shadow views now.

e280's frontend library for buildercore wizards. built on lit. powered by strata.

📦 npm i lit @e280/sly

🎭 #views, light-dom or shadow-dom.
🪝 #hooks, react-like composable hooks.

⏳ #spinners, animated loading spinners.
💅 #spa, tiny router for hashy single-page-apps.
🪙 #loot, drag-and-drop facilities.
🪄 #dom, the handy "it's not jquery" multitool.
🧪 https://sly.e280.org/ sly's silly testing page.



🎭 views — web ui building blocks

you didn't know you wanted these in your life. but you do.
sly views yoink the most premium ideas from react, preact, web components, lit, haunted, and more.

  • 🌗 light or shadow, render html nakedly, or tucked into a cozy shadow bubble.
  • 🪝 hooks-based, familiar react-style hooks.
  • ⚡ auto-reactive, views magically rerender on strata-compatible state changes.
  • 🪶 no special compiler, just god's honest javascript via lit-html tagged-templates.
  • 🧩 view aren't web components, no dom registration needed, nice typings.
  • 🔮 see codepen demo, plain html. (no build!)

🌚 shadow views

each shadow view gets its own comfy shadow-dom blankie to protect its local css, and it also supports slotting.

  • define your shadow view.
    import {css, html} from "lit"
    import {shadowView, useName, useCss, useSignal, renderIn} from "@e280/sly"
    
    export const MyCounter = shadowView((start: number, step = 1) => {
      useName("counter")
      useCss(css`button { color: cyan }`)
    
      const $count = useSignal(start)
      const increment = () => $count($count() + step)
    
      return html`
        <button @click="${increment}">${$count()}</button>
        <slot></slot>
      `
    })
  • render it on your page.
    renderIn(".demo", html`
      <h1>my cool counter demo</h1>
      ${MyCounter(234)}
    `)
    
    // <h1>my cool counter demo</h1>
    // <sly-shadow view="counter"></sly-shadow>
  • get more control: .with to nest children or set attrs.
    renderIn(".demo", html`
      <h1>my cool counter demo</h1>
    
      ${MyCounter.with({
        props: [234],
        attrs: {"data-whatever": 555},
        children: html`
          <p>woah, slotting support!</p>
        `,
      })}
    `)
  • fancy custom shadow settings, if you need. (default shown)
    import {SlyShadow} from "@e280/sly"
    
    const customShadowView = shadow.setup(() => {
      SlyShadow.register() // soft-registers <sly-shadow>
      const host = document.createElement("sly-shadow")
      const shadow = host.attachShadow({mode: "open"})
      return {host, shadow}
    })
    
    const MyShadowView = customShadowView(() => {
      return html`<p>shrouded in darkness</p>`
    })
  • there are light views too.
    // render naked on page with no shadow bubble, no css, no host element.
    export const MyLightCounter = lightView(() => {
      return html`<p>blinded by the light</p>`
    })

🍨 web components — custom elements

web components are a great way to give html authors some cool widgets to play with.
they're not as great as the primary building blocks for typescript app devs.

  • they use hooks like views, but they don't take props.
    import {html} from "lit"
    import {lightElement, shadowElement, register} from "@e280/sly"
    
    const MyLight = lightElement(() => html`hello`)
    const MyShadow = shadowElement(() => html`hello`)
    
    register({MyLight, MyShadow})
    <my-light></my-light>
    <my-shadow></my-shadow>
  • shadowDuplex, make a view and an element at the same time.
    this is great where as a library author, we want to supply a widget as both a sly view, and also standard web component.
    import {html} from "lit"
    import {shadowDuplex, register} from "@e280/sly"
    
    const [SumView, makeSumElement] = shadowDuplex(
      (a: number, b: number) => html`<p>sum: ${a + b}</p>`
    )
    // view usage in any html template
    SumView(1, 2)
    // register the element to the dom
    register({SumElement: makeSumElement(1, 2)})
    • the params are hard-baked into the component at registration time.
    • the common pattern here, is to pass an app context object to these duplexes.
    • hooks like useAttrs can query the element's runtime attributes.
    • there also a matching lightDuplex.



🪝 hooks — give your views the spark of life

👮 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 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.
    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")
    })
  • useUnmount, setup unmount handler.
    useUnmount(() => console.log("unmounted"))
  • useWake, run fn each time mounted, and return value.
    const whatever = useWake(() => {
      console.log("mounted")
      return 123
    })
    
    whatever // 123
  • useMounted, mount/unmount lifecycle, but also return a value.
    const whatever = useMounted(() => {
      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"))
  • 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() => {
      await nap(2000)
      return 123
    })
    $wait()
      // {done: true, ok: true, value: 123}
    await $wait.ready
      // 123
    const $wait = useWait(fnMakesExpensiveThing, thing => {
      // disposed when current view is unmounted.
      thing.dispose()
    })
  • useWaitFormal, start a strata#wait, but with a formal stz#ok ok/err result.
    const $wait = useWaitFormal(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($seconds() + 1)
    }))
  • 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

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 {shadowView, useWait, spinner} from "@e280/sly"
    
    const MyView = shadowView(() => {
    
      // ⏳️ 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 — single-page-app clientside 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.
    const navigate = 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.
    const route = router({
      "": () => html`<h1>home</h1>`,
      "about": () => html`<h1>about</h1>`,
      "project/{id}": ({id}) => html`<h1>project ${id}</h1>`,
    })
  • render the hash's route onto your page.
    const App = lightView(() => route($hash()))
    
    renderIn(".content", App())

💅 advanced spa usage for giant brains.

  • subrouting pattern for composable routers.
    // 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"
  • 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.
    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, register, renderIn} from "@e280/sly"

🪄 dom has some standalone fns that have broken free.

  • register web components, automatically dashifying the names (MyComponent becomes <my-component>).
    register({MyComponent, AnotherCoolComponent})
      // <my-component>
      // <another-cool-component>
  • renderIn content into an element.
    renderIn(".demo", html`<p>hello world</p>`)
    renderIn(element, html`<p>hello world</p>`)

🪄 dom queries

  • need an element.
    dom(".demo")
      // HTMLElement (or throws)
    // alias
    dom.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.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



i've been whittling away at sly and friends for many years in isolation.
this is my way of doing frontend. maybe it's the way of a madman in a shack. maybe it's a manifesto.
anyways i hope you think it's neat.
    — chase.

🧑‍💻 https://e280.org/

About

🦝 we're doing shadow views now

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages