web shadow views
npm install @e280/sly@e280's new lit-based frontend webdev library.
- ๐ #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
- ๐งช https://sly.e280.org/ โ our testing page
- โจshinyโจ โ our wip component library
@e280/sly ``sh`
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, @e280/sly/view
> or from specific subpackages like , @e280/sly/dom, etc...
> the crown jewel of sly `ts
view(use => () => htmlhello world
)
`- ๐ชถ 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$3
`ts
import {view, dom, BaseElement} from "@e280/sly"
import {html, css} from "lit"
`
- declare view
`ts
export const CounterView = view(use => (start: number) => {
use.styles(cssp {color: green}) const $count = use.signal(start)
const increment = () => $count.value++
return html
})
`
- $count is a strata signal (we like those)
- inject view into dom
`ts
dom.in(".app").render(html)
`
- ๐คฏ register view as web component
`ts
dom.register({
MyCounter: CounterView
.component()
.props(() => [1]),
})
`
`html
`$3
- optional settings for views you should know about
`ts
export const CoolView = view
.settings({mode: "open", delegatesFocus: true})
.render(use => (greeting: string) => html๐ ${greeting} )
`
- all attachShadow params (like mode and delegatesFocus) are valid settings
- note the we'll use in the next example lol$3
- views have this sick chaining syntax for supplying more stuff at the template injection site
`ts
dom.in(".app").render(htmlspongebob))
`
- props โ provide props and start a view chain
- attr โ set html attributes on the host element
- children โ add nested slottable content
- render โ end the view chain and render the lit directive$3
- you can start with a view,
`ts
export const GreeterView = view(use => (name: string) => {
return htmlhello ${name}
})
`
- view usage
`ts
GreeterView("pimsley")
`
then you can convert it to a component.
`ts
export class GreeterComponent extends (
GreeterView
.component()
.props(component => [component.getAttribute("name") ?? "unknown"])
) {}
`
- html usage
`html
`
- you can start with a component,
`ts
export class GreeterComponent extends (
view(use => (name: string) => {
return htmlhello ${name}
})
.component()
.props(component => [component.getAttribute("name") ?? "unknown"])
) {}
`
- html usage
`html
`
and it already has .view ready for you.
- view usage
`ts
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
`ts
.props(() => ["pimsley"])
`
the props fn receives the component instance, so you can query html attributes or instance properties
`ts
.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
`ts
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
`ts
dom("greeter-component").updateName("mortimer")
`
- typescript class wizardry
- โ smol-brain approach exports class value, but NOT the typings
`ts
export const GreeterComponent = (...)
`
- โ
giga-brain approach exports class value AND the typings
`ts
export class GreeterComponent extends (...) {}
`
- register web components to the dom
`ts
dom.register({GreeterComponent})
`
- oh and don't miss out on the insta-component shorthand
`ts
dom.register({
QuickComponent: view.component(use => htmlโก incredi),
})
`$3
- ๐ฎ 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
`ts
use.name("squarepants")
`
- use.styles โ attach stylesheets into the view's shadow dom
`ts
use.styles(css1, css2, css3)
`
(alias use.css)
- use.signal โ create a strata signal
`ts
const $count = use.signal(1) // read the signal
$count()
// write the signal
$count(2)
`
- derived signals
`ts
const $product = use.derived(() => $count() * $whatever())
`
- lazy signals
`ts
const $product = use.lazy(() => $count() * $whatever())
`
- go read the strata readme about this stuff
- use.once โ run fn at initialization, and return a value
`ts
const whatever = use.once(() => {
console.log("happens only once")
return 123
}) whatever // 123
`
- use.mount โ setup mount/unmount lifecycle
`ts
use.mount(() => {
console.log("view mounted") return () => {
console.log("view unmounted")
}
})
`
- use.wake โ run fn each time mounted, and return value
`ts
const whatever = use.wake(() => {
console.log("view mounted")
return 123
}) whatever // 123
`
- use.life โ mount/unmount lifecycle, but also return a value
`ts
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)
`ts
use.events({
keydown: (e: KeyboardEvent) => console.log("keydown", e.code),
keyup: (e: KeyboardEvent) => console.log("keyup", e.code),
})
`
- use.states โ internal states helper
`ts
const states = use.states()
states.assign("active", "cool")
`
`css
[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
`ts
const attrs = use.attrs({
name: String,
count: Number,
active: Boolean,
})
`
`ts
attrs.name // "chase"
attrs.count // 123
attrs.active // true
`
- use.attrs.{strings/numbers/booleans}
`ts
use.attrs.strings.name // "chase"
use.attrs.numbers.count // 123
use.attrs.booleans.active // true
`
- use.attrs.on
`ts
use.attrs.on(() => console.log("an attribute changed"))
`
- use.render โ rerender the view (debounced)
`ts
use.render()
`
- use.renderNow โ rerender the view instantly (not debounced)
`ts
use.renderNow()
`
- use.rendered โ promise that resolves after the next render
`ts
use.rendered.then(() => {
const slot = use.shadow.querySelector("slot")
console.log(slot)
})
`
- use.op โ start with an op based on an async fn
`ts
const op = use.op(async() => {
await nap(5000)
return 123
})
`
- use.op.promise โ start with an op based on a promise
`ts
const op = use.op.promise(doAsyncWork())
`$3
- make a ticker โ mount, cycle, and nap
`ts
import {cycle, nap} from "@e280/stz"
`
`ts
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
`ts
use.wake(() => use.rendered.then(() => {
console.log("after first render")
}))
`๐ชต๐ฆ sly base element
> @e280/sly/base
> the classic experience `ts
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.
$3
- "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$3
- declare your element class
`ts
export class MyElement extends BaseElement {
static styles = cssspan{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
}
}
`
- register your element to the dom
`ts
dom.register({MyElement})
`$3
- place the element in your html body
`html
`
- now you can interact with it
`ts
const myElement = dom("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 `ts
import {dom} from "@e280/sly"
`$3
- require an element
`ts
dom(".demo")
// HTMLElement (or throws)
`
`ts
// alias
dom.require(".demo")
// HTMLElement (or throws)
`
- maybe get an element
`ts
dom.maybe(".demo")
// HTMLElement | undefined
`
- all matching elements in an array
`ts
dom.all(".demo ul li")
// HTMLElement[]
`$3
- make a scope
`ts
dom.in(".demo") // selector
// Dom instance
`
`ts
dom.in(demoElement) // element
// Dom instance
`
- run queries in that scope
`ts
dom.in(demoElement).require(".button")
`
`ts
dom.in(demoElement).maybe(".button")
`
`ts
dom.in(demoElement).all("ol li")
`$3
- dom.register web components
`ts
dom.register({MyComponent, AnotherCoolComponent})
//
//
`
- dom.register automatically dashes the tag names (MyComponent becomes )
- dom.render content into an element
`ts
dom.render(element, htmlhello world
)
`
`ts
dom.in(".demo").render(htmlhello world
)
`
- dom.el little element builder
`ts
const div = dom.el("div", {"data-whatever": 123, "data-active": true})
//
`
- dom.elmer make an element with a fluent chain
`ts
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)
`ts
const div = dom.mk(html) // HTMLElement
`
- dom.events to attach event listeners
`ts
const detach = dom.events(element, {
keydown: (e: KeyboardEvent) => console.log("keydown", e.code),
keyup: (e: KeyboardEvent) => console.log("keyup", e.code),
})
`
`ts
const detach = dom.in(".demo").events({
keydown: (e: KeyboardEvent) => console.log("keydown", e.code),
keyup: (e: KeyboardEvent) => console.log("keyup", e.code),
})
`
`ts
// unattach those event listeners when you're done
detach()
`
- dom.attrs to setup a type-happy html attribute helper
`ts
const attrs = dom.attrs(element).spec({
name: String,
count: Number,
active: Boolean,
})
`
`ts
const attrs = dom.in(".demo").attrs.spec({
name: String,
count: Number,
active: Boolean,
})
`
`ts
attrs.name // "chase"
attrs.count // 123
attrs.active // true
`
`ts
attrs.name = "zenky"
attrs.count = 124
attrs.active = false // removes html attr
`
`ts
attrs.name = undefined // removes the attr
attrs.count = undefined // removes the attr
`
or if you wanna be more loosey-goosey, skip the spec
`ts
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 `ts
import {nap} from "@e280/stz"
import {Pod, podium, Op, loaders} from "@e280/sly"
`$3
- a pod represents an async operation in terms of json-serializable data
- there are three kinds of Pod
`ts
// loading pod
["loading"] // ready pod contains value 123
["ready", 123]
// error pod contains an error
["error", new Error()]
`$3
- get pod status
`ts
podium.status(["ready", 123])
// "ready"
`
- get pod ready value (or undefined)
`ts
podium.value(["loading"])
// undefined podium.value(["ready", 123])
// 123
`
- see more at podium.ts$3
- an Op wraps a pod with a signal for reactivity
- create an op
`ts
const op = new Op() // loading status by default
`
`ts
const op = Op.loading()
`
`ts
const op = Op.ready(123)
`
`ts
const op = Op.error(new Error())
`
- ๐ฅ create an op that calls and tracks an async fn
`ts
const op = Op.load(async() => {
await nap(4000)
return 123
})
`
- await for the next ready value (or thrown error)
`ts
await op // 123
`
- get pod info
`ts
op.pod // ["loading"]
op.status // "loading"
op.value // undefined (or value if ready)
`
`ts
op.isLoading // true
op.isReady // false
op.isError // false
`
- select executes a fn based on the status
`ts
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
`ts
op.morph(n => n + 1)
// ["ready", 124]
`
- you can combine a number of ops into a single pod like this
`ts
Op.all(Op.ready(123), Op.loading())
// ["loading"]
`
`ts
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 `ts
import {loaders} from "@e280/sly"
`$3
- create a loader fn
`ts
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?$3
- use your loader to render an op
`ts
return html ${loader(op, value => html)}
${value}
`
- 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
> hash router for single-page-apps `ts
import {spa, html} from "@e280/sly"
`$3
- make a spa router
`ts
const router = new spa.Router({
routes: {
home: spa.route("#/", async() => htmlhome),
settings: spa.route("#/settings", async() => htmlsettings),
user: spa.route("#/user/{userId}", async({userId}) => htmluser ${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
`ts
return html
`
- 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
`ts
await router.nav.settings.go()
// goes to "#/settings"
`
- go to user page
`ts
await router.nav.user.go("123")
// goes to "#/user/123"
`$3
- generate a route's hash string
`ts
const hash = router.nav.user.hash("123")
// "#/user/123" html
user 123
`
- check if a route is the currently-active one
`ts
const hash = router.nav.user.active
// true
`
- force-refresh the router
`ts
await router.refresh()
`
- force-navigate the router by hash
`ts
await router.refresh("#/user/123")
`
- get the current hash string (normalized)
`ts
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
`ts
router.dispose()
// stop listening to hashchange events
`๐ช๐ฆ loot
> @e280/sly/loot
> drag-and-drop facilities `ts
import {loot, view, dom} from "@e280/sly"
import {ev} from "@e280/stz"
`$3
> accept the user dropping stuff like files onto the page
- setup drops
`ts
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
`ts
view(() => () => html)
`
- vanilla-js whole-page example
`ts
// 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
`css
[data-indicator] {
border: 0.5em dashed cyan;
}
`$3
> setup drag-and-drops between items within your page
- declare types for your draggy and droppy things
`ts
// 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
`ts
const dnd = new loot.DragAndDrops({
acceptDrop: (event, money, bag) => {
console.log("drop!", {money, bag})
},
})
`
- attach dragzone listeners (there can be many dragzones...)
`ts
view(use => () => {
const money = use.once((): Money => ({value: 280}))
const dragzone = use.once(() => dnd.dragzone(() => money)) return html
})
`
- attach dropzone listeners (there can be many dropzones...)
`ts
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
})
`$3
- 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`)