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/stzNote
- 🔥 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
usefamily 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> ` })
$countis 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
modeanddelegatesFocus) 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>
.viewready for you.- view usage
GreeterComponent.view("pimsley")
- html usage
- understanding
.component(BaseElement)and.props(fn).propstakes 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"])
.componentaccepts 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])
.componentprovides 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
usehooks actually matters..
you must not call these hooks underifconditionals, orforloops, or in callbacks, or after a conditionalreturnstatement, 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)
derivedsignalsconst $product = use.derived(() => $count() * $whatever())
lazysignalsconst $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.attrsis 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"requirean elementdom(".demo") // HTMLElement (or throws)
// alias dom.require(".demo") // HTMLElement (or throws)
maybeget an elementdom.maybe(".demo") // HTMLElement | undefined
allmatching 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.registerweb componentsdom.register({MyComponent, AnotherCoolComponent}) // <my-component> // <another-cool-component>
dom.registerautomatically dashes the tag names (MyComponentbecomes<my-component>)
dom.rendercontent into an elementdom.render(element, html`<p>hello world</p>`)
dom.in(".demo").render(html`<p>hello world</p>`)
dom.ellittle element builderconst div = dom.el("div", {"data-whatever": 123, "data-active": true}) // <div data-whatever="123" data-active></div>
dom.elmermake an element with a fluent chainconst div = dom.elmer("div") .attr("data-whatever", 123) .attr("data-active") .children("hello world") .done() // HTMLElement
dom.mkmake 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.eventsto 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.attrsto 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.orgusing history.replaceState - you can provide
loaderoption if you want to specify the loading spinner (defaults toloaders.make()) - you can provide
notFoundoption, if you want to specify what is shown on invalid routes (defaults to() => null) - when
autois true (default), the router calls.refresh()and.listen()in the constructor.. set it tofalseif you want manual control - you can set
autooption 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 ifDragEventcontains 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