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/
@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...
@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
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> ` })
$count
is a strata signal (we like those)
- 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>
- 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
anddelegatesFocus
) are validsettings
- note the
<slot></slot>
we'll use in the next example lol
- all attachShadow params (like
- 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 chainattr
— set html attributes on the<sly-view>
host elementchildren
— add nested slottable contentrender
— end the view chain and render the lit directive
- you can start with a view,
export const GreeterView = view(use => (name: string) => { return html`<p>hello ${name}</p>` })
- view usage
GreeterView("pimsley")
export class GreeterComponent extends ( GreeterView .component() .props(component => [component.getAttribute("name") ?? "unknown"]) ) {}
- html usage
<greeter-component name="pimsley"></greeter-component>
- view usage
- 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>
.view
ready for you.- view usage
GreeterComponent.view("pimsley")
- html usage
- understanding
.component(BaseElement)
and.props(fn)
.props
takes a fn that is called every render, which returns the props given to the viewthe props fn receives the component instance, so you can query html attributes or instance properties.props(() => ["pimsley"])
.props(component => [component.getAttribute("name") ?? "unknown"])
.component
accepts a subclass ofBaseElement
, so you can define your own properties and methods for your component classconst 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 typingsdom<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 (...) {}
- ❌ smol-brain approach exports class value, but NOT the typings
- 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`), })
- 👮 follow the hooks rules
just like react hooks, the execution order of sly's
use
hooks actually matters..
you must not call these hooks underif
conditionals, orfor
loops, or in callbacks, or after a conditionalreturn
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
(alias
use.styles(css1, css2, css3)
use.css
) - use.signal — create a strata signal
const $count = use.signal(1) // read the signal $count() // write the signal $count(2)
derived
signalsconst $product = use.derived(() => $count() * $whatever())
lazy
signalsconst $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.states — internal 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.attrsconst 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())
- 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") }))
@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.
- "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
- 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})
- 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"
@e280/sly/dom
the "it's not jquery!" multitool
import {dom} from "@e280/sly"
require
an elementdom(".demo") // HTMLElement (or throws)
// alias dom.require(".demo") // HTMLElement (or throws)
maybe
get an elementdom.maybe(".demo") // HTMLElement | undefined
all
matching 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).require(".button")
dom.in(demoElement).maybe(".button")
dom.in(demoElement).all("ol li")
dom.register
web componentsdom.register({MyComponent, AnotherCoolComponent}) // <my-component> // <another-cool-component>
dom.register
automatically dashes the tag names (MyComponent
becomes<my-component>
)
dom.render
content into an elementdom.render(element, html`<p>hello world</p>`)
dom.in(".demo").render(html`<p>hello world</p>`)
dom.el
little element builderconst 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 chainconst 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 listenersconst 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 helperconst 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
or if you wanna be more loosey-goosey, skip the specattrs.name = undefined // removes the attr attrs.count = undefined // removes the attr
const a = dom.in(".demo").attrs a.strings.name = "pimsley" a.numbers.count = 125 a.booleans.active = true
@e280/sly/ops
tools for async operations and loading spinners
import {nap} from "@e280/stz"
import {Pod, podium, Op, loaders} from "@e280/sly"
- 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()]
- 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
- 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
@e280/sly/loaders
animated loading spinners for ops
import {loaders} from "@e280/sly"
- 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?
- 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
@e280/sly/spa
hash router for single-page-apps
import {spa, html} from "@e280/sly"
- 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 toe280.org
using history.replaceState - you can provide
loader
option if you want to specify the loading spinner (defaults toloaders.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 tofalse
if you want manual control - you can set
auto
option false if you want to omit the default initial refresh and listen calls
- all route strings must start with
- 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"
- go to settings page
- 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
@e280/sly/loot
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
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)
- 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 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.hasFiles(event)
— return true ifDragEvent
contains any files (useful inpredicate
)loot.files(event)
— returns an array of files in a drop'sDragEvent
(useful inacceptDrop
)
reward us with github stars
build with us at https://e280.org/ but only if you're cool