Compare commits

..

No commits in common. "main" and "page" have entirely different histories.
main ... page

9 changed files with 603 additions and 908 deletions

View File

@ -1,85 +0,0 @@
class ChildObserver extends MutationObserver {
constructor() {
super(mutations => {
for (const mutation of mutations) {
mutation.target.dispatchEvent(new CustomEvent("change", {detail: mutation}))
}
})
}
observe(element) {
MutationObserver.prototype.observe.call(this, element, { childList: true })
}
}
const childObserver = new ChildObserver()
export const lense = (methods, extra) => {
if (extra) return lense(extra)(methods)
const traps = {
get(target, prop) {
if (prop === "length") {
return target.children.length
} else if (prop === Symbol.iterator) {
return function*() {
for (const child of target.children) {
yield methods.get(child)
}
}
} else if (prop.match?.call(prop, /^[0-9]+$/)) {
const child = target.children[prop]
if (child) return methods.get(child)
return child
} else {
return Array.prototype[prop]
}
},
set(target, prop, value) {
if (prop.match?.call(prop, /^[0-9]+$/)) {
const child = target.children[prop]
if (child) {
methods.set(child, value)
return true
} else {
for (let i = target.children.length; i < Number(prop); i++) {
target.appendChild(methods.new(undefined))
}
const element = methods.new(value)
target.appendChild(element)
if (methods.get(element) !== value)
methods.set(element, value)
return true
}
} else if (prop == "length") {
if (value == target.children.length)
return true
else
return false
}
},
deleteProperty(target, prop) {
if (prop.match?.call(prop, /^[0-9]+$/)) {
const child = target.children[prop]
if (child) child.remove()
return true
}
},
has(target, prop) {
return (prop === Symbol.iterator) || (prop in target.children) || (prop in Array.prototype)
}
}
return element => {
const proxy = new Proxy(element, traps)
if (methods.event) childObserver.observe(element)
if (typeof methods.event === "function") element.addEventListener("change", event => {
methods.event(proxy, element, event.detail)
})
return proxy
}
}
export default lense

432
index.html Normal file
View File

@ -0,0 +1,432 @@
<html theme=dark>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width">
<script type="module" src="https://cdn.jsdelivr.net/gh/darkwiiplayer/components@master/TypeWriter.js"></script>
<style>
@import
/* url('https://cdn.jsdelivr.net/gh/darkwiiplayer/css@main/all.css') */
url('https://darkwiiplayer.github.io/css/all.css') layer(framework);
@import
/* url('https://cdn.jsdelivr.net/gh/darkwiiplayer/css@main/schemes/talia.css') */
url('https://darkwiiplayer.github.io/css/schemes/talia.css') layer(theme);
@import url('styles.css') layer(site);
.jsdelivr-badge {
filter: saturate(.4) hue-rotate(250deg);
border-radius: .2em;
}
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css">
<script>
const scrollHook = () => {
document.body.setAttribute("scroll", window.scrollY)
}
window.addEventListener("scroll", scrollHook)
document.addEventListener("readystatechange", scrollHook)
const observer = new IntersectionObserver(events => {}, {
rootMargin: '-1px 0px 0px 0px',
threshold: [1]
})
</script>
<script type="module">
import hljs from 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/es/highlight.min.js';
import lang_html from "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/es/languages/xml.min.js"
hljs.registerLanguage("html", lang_html)
document.querySelectorAll('code[lang="js"]').forEach(code => {
code.innerHTML = hljs.highlight("javascript", code.innerText).value
})
</script>
<script type="module">
import "./js/SkoomaShowcase.js"
</script>
<a name=top></a>
<header class="fixed">
<nav class="bar">
<ul>
<li><a href="#top">Top</a></li>
<li><a href="#getting-started">Getting Started</a></li>
<li><a href="https://github.com/darkwiiplayer/skooma-js">GitHub</a></li>
<li><a href="https://www.npmjs.com/package/skooma">npm</a></li>
</ul>
</nav>
<img class="jsdelivr-badge" src="https://data.jsdelivr.com/v1/package/npm/skooma/badge">
</header>
<page-hero cover=60>
<!-- <img src="https://picsum.photos/1920/1080"> -->
<hgroup>
<h1 style="font-size: 6vw">Skooma</span></h1>
<code lang="js">import { html } from "skooma.js"</code>
<p>
A new way of building
<type-writer loop>
<span>HTML</span>,
<span>components</span> and
<span>applications</span>
</type-writer>
in vanilla JavaScript
</p>
</hgroup>
<flex-row gap=1>
<!--<a class="button" href="#elevator-pitch">Elevator Pitch</a>-->
<a class="button" href="#getting-started">Get Started</a>
</flex-row>
</page-hero>
<vertical-spacer style="height: 6rem"></vertical-spacer>
<main>
<section id="elevator-pitch" class="content-width">
<h2>Elevator Pitch</h2>
<p>
Skooma lets you <u>elegantly</u> express nested DOM structures in <u>plain JavaScript</u>.
<br>
It makes your code more <u>declarative</u> without re-inventing language features like loops and conditions.
</p>
<skooma-showcase>
<code><div contenteditable="false">return html.p(
"Text Node",
html.br(),
html.b("HTML Node")
)</div></code>
</skooma-showcase>
<p>
Skooma aims to be what <code>document.createElement</code> should have been.
It creates a node, sets classes and attributes, inserts content and attaches event listeners.
</p>
<p>Some key characteristics:</p>
<ul>
<li> <b>No build steps required</b>
<li> <b>Plays nice with web components</b>
<li> <b>Just vanilla JavaScript</b>
<li> Code mirrors output in structure
<li> Single Responsibility: Generating HTML
<li> No closing tags
</ul>
</section>
<section id="getting-started" class="content-width">
<h2>Getting Started</h2>
<section>
<p class="important">
Trying out skooma is super easy!<br>
Just import the <code>html</code> export into your script and start generating DOM nodes.
</p>
<pre><code lang=js>import {html} from "https://cdn.jsdelivr.net/npm/skooma@1.3.1/skooma.min.js"</code></pre>
</section>
<section>
<p>
To create elements, skooma exposes the <code>html</code> proxy.
Accessing this proxy with any value will return a node factory function.
<br>
In its most basic form, these functions will simply generate a new DOM node
of the respective type.
</p>
<skooma-showcase preview="false">
<code>
<div contenteditable="false">return html.div()</div>
</code>
</skooma-showcase>
<p>
If you quickly want to render your HTML to a website to see the result,
the component helper found in <code>sckooma/state.js</code> makes this a lot easier.<br>
This wrapper registers a new custom element to insert the results of a function in your document.
</p>
<pre><code lang=js>import {component} from "https://cdn.jsdelivr.net/npm/skooma@1.3.1/state.min.js"
const myComponent = () =&gt; html.div("Rendered Component")
component(myComponent) //Registers it as &lt;my-component&gt;</code></pre>
<p>
This wrapper also provides some state-management features that will be described in a separate section.
</p>
<p>
Of course you can also just call <code lang=js>document.body.append(html.span("My Text"))</code> in your script.
</p>
</section>
</section>
<section class="content-width">
<h3>Basic DOM generation</h3>
<section>
<p>
Content can be added to a node by simply passing it as arguments to the function.
String arguments get inserted as text nodes, and DOM nodes are simply appended to the child list.
<br>
This results in an easy way to compose nested DOM structures.
</p>
<skooma-showcase preview="false">
<code>
<div contenteditable="false">return html.span(
"Testing ",
html.u("stuff")
)</div>
</code>
</skooma-showcase>
</section>
<section>
<p>
Attributes are added by passing an object to the function,
where each key-value pair gets turned into an attribute on
the generated node.
</p>
<skooma-showcase preview="false">
<code>
<div contenteditable="false">return html.span({
id: "warning",
class: ["danger", "bold"],
// Arrays get joined with spaces
})</div>
</code>
</skooma-showcase>
</section>
<section>
<p>
When trying to assign a function as the value of an attribute,
skooma will instead register that function as an event handler
with the attribute name being used the event name instead.
</p>
<skooma-showcase code="false">
<code>
<div contenteditable="false">return html.button(
"Click Me!",
{ click: event => {
alert("Button clicked :3")
} }
)</div>
</code>
</skooma-showcase>
</section>
</section>
<section class="content-width">
<h2>Leveraging JS features</h2>
<p>
When generating HTML with skooma, you never stop writing JavaScript.
This means that all JavaScript features are available anywhere
inside your code. Functions like <code>filter</code> or <code>map</code>
can be applied directly to your data, nodes can be assigned to
variables right as you create them, and the syntax is always 100%
pure javascript as most browsers and dev tools understand it.
</p>
<skooma-showcase code="false">
<code>
<div contenteditable="false">return html.ul(
["u", "b", "i"].map(
type => html.li(html[type](
`&lt;${type}&gt; element`
))
)
)</div>
</code>
</skooma-showcase>
<p>
A more complex example that assembles an array of objects into a table:
</p>
<skooma-showcase code="false">
<code>
<div contenteditable="false">const table = (...rows) =&gt; {
const keys = new Set()
for (const row of rows) {
Object.keys(row).forEach(
key =&gt; keys.add(key)
)
}
const header = Array.from(keys)
return html.table(
{class: "full-width"},
html.thead(
html.tr(header.map(
key =&gt; html.th(key)
))
),
html.tbody (
rows.map(row => html.tr(
header.map(
key => html.td(
row[key] ?? empty
)
)
))
)
)
}
return table(
{name: "Alice", hobby: "fishing"},
{name: "Bob", hobby: "cooking", age: 22}
)</div>
</code>
</skooma-showcase>
</section>
<section class="content-width">
<h2>Managing state in Skooma</h2>
<pre><code lang=js>import {State} from 'skooma/state.js'</code></pre>
<p>
Reactivity and state management in skooma are split between the core module and the <code>state</code> module.
</p>
<h3>Generator Functions</h3>
<p>
The core <code>skooma.js</code> module understands a state object to be anything that is
</p>
<ol>
<li> Not a DOM node
<li> Has a <code>value</code> property
<li> Has an <code>addEventListener</code> method
</ol>
<p>
When such an object is passed into a generator function as either a child element or an attribute,
skooma will use the <code>value</code> of the state object and register a <code>"change"</code> event
that replaces the attribute or child element with the current <code>value</code>.
</p>
<p>
One special case worth mentioning is when an attribute is assigned a state object with a function as its value.
In these cases, the function gets registered as an event handler just like if it was assigned directly,
but updating the state will remove the event handler and register a new event handler with the current <code>value</code>.
</p>
<h3>The <code>state</code> module</h3>
<p>
This module primarily exposts the <code>State</code> class,
which extends the <code>EventTarget</code> class.
</p>
<p>
Every state object exposes a proxy object via the <code>proxy</code> property.
This proxy can be used like any normal object, but setting any property on it will dispatch a <code>"change"</code> event on the corresponding state object.
The proxy object itself also has a getter and setter for the <code>value</code> property, which gets forwarded directly to <code>proxy.value</code> for easier access.
</p>
<p>
This means that any <code>State</code> object satisfies the API of a state object from the previous section,
meaning they can be used to build reactive components.
</p>
<skooma-showcase code="false">
<code>
<div contenteditable="false">const counter = new State({value: 0})
counter.valueChanged = newValue =&gt;
console.log(`Value: ${newValue}`)
return html.flexColumn(
{gap: 1},
html.button(
"Click Me! ",
html.span(counter),
{ click: () =&gt; {
counter.value += 1
}}
),
html.button(
"Clear",
{ click: () =&gt; {
counter.value = 0
}}
)
)</div>
</code>
</skooma-showcase>
<!-- TODO: Describe constructor options -->
<p>
The basic <code>State</code> object is backed by a plain JS object, so their attributes are unique and do not persist page reload.<br>
By contrast, the <code>StoredState</code> class, which extends the core <code>State</code> class, is backed by a <code>Storage</code> object like
<code>window.localStorage</code> or <code>window.sessionStorage</code>, meaning that they persist page reloads.<br>
Additionally, they detect changes both from the current as well as from other browser tabs/windows, so any updates of the state
get propagated automatically to all states backed by the same storage.
</p>
</section>
<section class="content-width">
<h2>A simple Todo list</h2>
<p>
A simple, but not completely bare-bones todo list application,
using nothing more than Skooma and the CSS already present
on this page to save some code.
</p>
<skooma-showcase code="false">
<code>
<div contenteditable="false">let todo, input
const task = value =>
html.flexRow (
{class: ["todo"], gap: 1},
value,
html.span("[x]", {
style: {
color: "var(--primary-6)",
cursor: "pointer"
},
click: event =&gt; {
event
.target
.closest(".todo")
.remove()
}
})
)
return todo =
html.flexColumn(
{gap: 1},
input=html.input({
type: "text",
placeholder:
"Do some stuff",
}),
html.button("Add Task",
{
click: event =&gt; {
todo.append(task(
input.value ||
input.placeholder
))
}
})
)</div>
</code>
</skooma-showcase>
</section>
</main>
<footer class="inset box">
<flex-row gap=1>
<div>
Skooma is great!
</div>
</flex-row>
</footer>
</html>

124
js/SkoomaShowcase.js Normal file
View File

@ -0,0 +1,124 @@
import {html,empty} from "https://cdn.jsdelivr.net/gh/darkwiiplayer/skooma-js@f79e7a9/skooma.js"
import {State} from "https://cdn.jsdelivr.net/gh/darkwiiplayer/skooma-js@f79e7a9/state.js"
import element from "https://darkwiiplayer.github.io/easier-elements-js/easier-elements.js"
import hljs from 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/es/highlight.min.js';
import lang_html from "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/es/languages/xml.min.js"
hljs.registerLanguage("html", lang_html)
const capture = event => event.stopPropagation()
const css = `
:host {
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
gap: var(--padding, 1em);
padding: var(--padding, 1em);
position: relative;
}
@media (max-width: 60em) {
:host {
display: flex;
flex-flow: column;
}
}
.error {
font-family: "Courier New", sans-serif;
grid-column: span 2;
}
.error:empty { display: none; }
.error:not(:empty)~* { display: none; }
.hidden { display: none; }
html-preview {
contain: inline-size layout style paint;
}
.edit {
left: -1.4em;
z-index: 10;
position: absolute;
display: block;
content: '🖉';
line-height: 100%;
opacity: .2;
font-size: 2em;
cursor: pointer;
}
.edit.editing {
opacity: .6;
}
`
const theme = html.link({
rel: "stylesheet",
href: "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css"
})
element(class SkoomaShowcase extends HTMLElement {
constructor() {
super()
this.attachShadow({mode: "open"})
this.addEventListener("input", _ => {
this.render()
})
}
get editable() {
return this.querySelector('[contenteditable="true"]')
}
format() {
if (!this.editable) {
const code = this.querySelector('[contenteditable]')
code.innerHTML = hljs.highlight("javascript", code.innerText).value
}
}
connectedCallback() {
this.shadowRoot.replaceChildren(
html.slot(),
html.span("🖉", {
class: "edit",
click: ({target: button}) => {
this.querySelectorAll("[contenteditable]")
.forEach(item => {
if (item.contentEditable == "true") {
item.contentEditable = "false"
button.classList.remove("editing")
this.format()
} else {
item.contentEditable = "true"
button.classList.add("editing")
item.innerText = item.innerText
}
})
}
}),
html.style(css),
...Array.from(document.styleSheets).map(sheet => sheet.ownerNode.cloneNode(true)),
theme.cloneNode(true),
this.error = html.div({class: ["error"]}),
this.output = html.code(),
this.preview = html.htmlPreview({input: capture}),
)
this.format()
this.render()
}
render() {
const code = this.querySelector("code").innerText
try {
const fn = new Function("html", "empty", "State", code)
const result = fn(html, empty, State)
this.error.replaceChildren()
this.output.innerHTML = hljs.highlight("html", result.outerHTML).value
this.preview.classList.toggle("hidden", this.getAttribute("preview") === "false")
this.output.classList.toggle("hidden", this.getAttribute("code") === "false")
this.preview.replaceChildren(result)
} catch (error) {
console.error(error.stack)
this.error.innerText = error.stack
}
}
})

View File

@ -1,22 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,21 +0,0 @@
{
"name": "skooma",
"version": "1.7.0",
"author": "darkwiiplayer",
"license": "Unlicense",
"main": "skooma.js",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/darkwiiplayer/skooma-js"
},
"homepage": "https://darkwiiplayer.github.io/skooma-js",
"keywords": [
"skooma",
"components",
"functional",
"html",
"ui",
"utility"
]
}

148
readme.md
View File

@ -1,148 +0,0 @@
# Skooma
A functional-friendly helper library for procedural DOM generation and
templating.
```js
import {html} from "skooma.js"
```
## Overview
```js
const text = new State({value: "Skooma is cool"})
setTimeout(() => {text.value = "Skooma is awesome!"}, 1e5)
document.body.append(html.div(
html.h1("Hello, World!"),
html.p(text, {class: "amazing"}),
html.button("Show Proof", {click: event => { alert("It's true!") }})
))
```
## Interface / Examples
### Basic DOM generation
Accessing the `html` proxy with any string key returns a new node generator
function:
```js
html.div("Hello, World!")
```
Attributes can be set by passing objects to the generator:
```js
html.div("Big Text", {style: "font-size: 1.4em"})
```
Complex structures can easily achieved by nesting generator functions:
```js
html.div(
html.p(
html.b("Bold Text")
)
)
```
For convenience, arrays assigned as attributes will be joined with spaces:
```js
html.a({class: ["button", "important"]})
```
Assigning a function as an attribute will instead attach it as an event
listener:
```js
html.button("Click me!", {click: event => {
alert("You clicked the button.")
}})
```
<!-- TODO: Document special keys -->
Generators can be called with many arguments. Arrays get iterated recursively as
if they were part of a flat argument list.
### Generating Text Nodes
```js
text("Hello, World")
// Wraps document.createTextNode
text()
// Defaults to empty string instead of erroring
text(null)
// Non-string arguments still error
text`Hello, World!`
// returns a new document fragment containing the text node "Hello, World!"
text`Hello, ${user}!`
// returns a document fragment containing 3 nodes:
// "Hello, ", the interpolated value of `user` and "!"
text`Hello, ${html.b(user)}!`
// Text node for Hello, the <b> tag with the user's name, and a text node for !
```
## handle
```js
import {handle} from 'skooma.js'
```
Since it is common for event handlers to call `preventDefault()`, skooma
provides a helper function called `handle` with the following definition:
```js
fn => event => { event.preventDefault(); return fn(event) }
```
## A few more examples:
Create a Button that deletes itself:
```js
document.body.append(
html.button("Delete Me", {click: event => event.target.remove()})
)
```
Turn a two-dimensional array into an HTML table:
```js
const table = rows =>
html.table(html.tbody(rows.map(
row => html.tr(row.map(
cell => html.rd(cell, {dataset: {
content: cell.toLowerCase(),
}})
))
)))
```
A list that you can add items to
```js
let list, input = ""
document.body.append(html.div([
list=html.ul(),
html.input({type: 'text', input: e => input = e.target.value}),
html.button({click: event => list.append(html.li(input))}, "Add"),
]))
```
A list that you can also delete items from
```js
const listItem = content => html.li(
html.span(content), " ", html.a("[remove]", {
click: event => event.target.closest("li").remove(),
style: { cursor: 'pointer', color: 'red' },
})
)
let list, input = ""
document.body.append(html.div([
list=html.ul(),
html.input({type: 'text', input: e => input = e.target.value}),
html.button({click: event => list.append(listItem(input))}, "Add"),
]))
```

219
skooma.js
View File

@ -1,219 +0,0 @@
/*
A functional HTML generation library.
Example:
html.label(
html.span("Delete everything", {class: ["warning", "important"]}),
html.button("Click", {onClick: e => document.body.innerHTML=""}),
)
or
html.ul([1, 2, 3, 4, 5].map(x => html.li(x)), {class: "numbers"})
*/
// Keep a referee alive until a referrer is collected
const weakReferences = new WeakMap()
const untilDeathDoThemPart = (referrer, reference) => {
if (!weakReferences.has(referrer)) {
weakReferences.set(referrer, new Set())
}
weakReferences.get(referrer).add(reference)
}
export const empty = Symbol("Explicit empty argument for Skooma")
const keyToPropName = key => key.replace(/^[A-Z]/, a => "-"+a).replace(/[A-Z]/g, a => '-'+a.toLowerCase())
const insertStyles = (rule, styles) => {
for (const [key, value] of Object.entries(styles))
if (typeof value == "undefined")
rule.removeProperty(keyToPropName(key))
else
rule.setProperty(keyToPropName(key), value.toString())
}
const parseAttribute = (attribute) => {
if (typeof attribute == "string" || typeof attribute == "number")
return attribute
else if (attribute && "join" in attribute)
return attribute.join(" ")
else
return JSON.stringify(attribute)
}
const defined = (value, fallback) => typeof value != "undefined" ? value : fallback
const getCustom = args => args.reduce(
(current, argument) => Array.isArray(argument)
? defined(getCustom(argument), current)
: (argument && typeof argument == "object")
? defined(argument.is, current)
: current
,undefined
)
const isElement = object => HTMLElement.prototype.isPrototypeOf(object)
const isReactive = object => object
&& (typeof object == "object")
&& ("addEventListener" in object)
&& ("value" in object)
const toChild = arg => {
if (typeof arg == "string" || typeof arg == "number") {
return document.createTextNode(arg)
} else if (isElement(arg)) {
return arg
} else if (isReactive(arg)) {
return reactiveChild(arg)
}
}
const reactiveChild = reactive => {
const ref = new WeakRef(toChild(reactive.value))
reactive.addEventListener("change", () => {
const value = ref.deref()
if (value)
value.replaceWith(reactiveChild(reactive))
}, {once: true})
untilDeathDoThemPart(ref.deref(), reactive)
return ref.deref()
}
const specialAttributes = {
value: {
get: element => element.value,
set: (element, value) => {
element.setAttribute("value", value)
element.value = value
},
hook: (element, callback) => { element.addEventListener("input", callback) }
},
style: {
set: (element, value) => { insertStyles(element.style, value) }
},
dataset: {
set: (element, value) => {
for (const [attribute2, value2] of Object.entries(value)) {
element.dataset[attribute2] = parseAttribute(value2)
}
}
},
shadowRoot: {
set: (element, value) => {
parseArgs((element.shadowRoot || element.attachShadow({mode: "open"})), null, value)
}
}
}
const setAttribute = (element, attribute, value, cleanupSignal) => {
const special = specialAttributes[attribute]
if (isReactive(value))
setReactiveAttribute(element, attribute, value)
else if (typeof value === "function")
element.addEventListener(attribute.replace(/^on[A-Z]/, x => x.charAt(x.length-1).toLowerCase()), value, {signal: cleanupSignal})
else if (special) {
special.set(element, value)
}
else if (value === true)
{if (!element.hasAttribute(attribute)) element.setAttribute(attribute, '')}
else if (value === false)
element.removeAttribute(attribute)
else {
element.setAttribute(attribute, parseAttribute(value))
}
}
const setReactiveAttribute = (element, attribute, reactive, abortController) => {
untilDeathDoThemPart(element, reactive)
if (abortController) abortController.abort()
abortController = new AbortController()
const ref = new WeakRef(element)
setAttribute(element, attribute, reactive.value, abortController.signal)
reactive.addEventListener("change", () => {
const element = ref.deref()
if (element)
setReactiveAttribute(element, attribute, reactive, abortController)
}, {once: true})
const special = specialAttributes[attribute]
if (special?.hook) {
special.hook(element, () => {
const value = special.get(element, attribute)
if (value != reactive.value) reactive.value = value
})
}
}
const parseArgs = (element, before, ...args) => {
if (element.content) element = element.content
for (const arg of args) if (arg !== empty) {
const child = toChild(arg)
if (child)
element.insertBefore(child, before)
else if (arg === undefined || arg == null)
console.warn(`An argument of type ${typeof arg} has been ignored`, element)
else if (typeof arg == "function")
arg(element)
else if ("length" in arg)
parseArgs(element, before, ...arg)
else
for (const key in arg)
setAttribute(element, key, arg[key])
}
}
const node = (name, args, options) => {
let element
const custom = getCustom(args)
const opts = custom && {is: String(custom)}
if ("nameFilter" in options) name = options.nameFilter(name)
if (options.xmlns)
element = document.createElementNS(options.xmlns, name, opts)
else
element = document.createElement(name, opts)
parseArgs(element, null, args)
return element
}
const nameSpacedProxy = (options={}) => new Proxy(Window, {
get: (_target, prop, _receiver) => { return (...args) => node(prop, args, options) },
has: (_target, _prop) => true,
})
export const html = nameSpacedProxy({nameFilter: name => name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()})
export const svg = nameSpacedProxy({xmlns: "http://www.w3.org/2000/svg"})
export default html
// Other utility exports
// Wraps an event handler in a function that calls preventDefault on the event
export const handle = fn => event => { event.preventDefault(); return fn(event) }
// Wraps a list of elements in a document fragment
export const fragment = (...elements) => {
const fragment = new DocumentFragment()
for (const element of elements)
fragment.append(element)
return fragment
}
// Turns a template literal into document fragment.
// Strings are returned as text nodes.
// Elements are inserted in between.
const textFromTemplate = (literals, items) => {
const fragment = new DocumentFragment()
for (const key in items) {
fragment.append(document.createTextNode(literals[key]))
fragment.append(items[key])
}
fragment.append(document.createTextNode(literals.at(-1)))
return fragment
}
export const text = (data="", ...items) =>
typeof data == "object" && "at" in data
? textFromTemplate(data, items)
: document.createTextNode(data)

413
state.js
View File

@ -1,413 +0,0 @@
const abortRegistry = new FinalizationRegistry(controller => controller.abort())
const camelToKebab = string => string.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`)
const kebabToCamel = string => string.replace(/([a-z])-([a-z])/g, (_, a, b) => a+b.toUpperCase())
export class ChangeEvent extends Event {
#final
constructor(...changes) {
super('change')
this.changes = changes
}
get final() {
if (!this.#final) {
this.#final = new Map(this.changes)
}
return this.#final
}
}
export class SimpleState extends EventTarget {}
export class MapStorage extends Storage {
#map = new Map()
key(index) {
return [...this.#map.keys()][index]
}
getItem(keyName) {
if (this.#map.has(keyName))
return this.#map.get(keyName)
else
return null
}
setItem(keyName, keyValue) {
this.#map.set(keyName, String(keyValue))
}
removeItem(keyName) {
this.#map.delete(keyName)
}
clear() {
this.#map.clear()
}
}
export class State extends SimpleState {
#target
#options
#queue
#forwardCache
#abortController
#nested = new Map()
#weakRef = new WeakRef(this)
static isState(object) { return SimpleState.prototype.isPrototypeOf(object) }
constructor(target={}, options={}) {
super()
this.#abortController = new AbortController
abortRegistry.register(this, this.#abortController)
this.#options = options
this.#target = target
this.values = new Proxy(target, {
set: (_target, prop, value) => {
const old = this.get(prop)
if (old !== value) {
this.emit(prop, value)
if (this.#options.shallow) {
if (State.isState(old)) this.disown(prop, old)
if (State.isState(value)) this.adopt(prop, value)
}
this.set(prop, value)
}
return true
},
get: (_target, prop) => this.get(prop),
})
this.addEventListener
// Try running a "<name>Changed" method for every changed property
// Can be disabled to maybe squeeze out some performance
if (options.methods ?? true) {
this.addEventListener("change", ({final}) => {
final.forEach((value, prop) => {
if (`${prop}Changed` in this) this[`${prop}Changed`](value)
})
})
}
}
// When you only need one value, you can skip the proxy.
set value(value) { this.values.value = value }
get value() { return this.values.value }
adopt(prop, state) {
let handlers = this.#nested.get(state)
if (!handlers) {
// Actual adoption
handlers = new Map()
this.#nested.set(state, handlers)
}
const ref = this.#weakRef
const handler = () => ref.deref()?.emit(prop, state)
handlers.set(prop, handler)
state.addEventListener("change", handler, {signal: this.#abortController.signal})
}
disown(prop, state) {
const handlers = this.#nested.get(state)
const handler = handlers.get(prop)
state.removeEventListener("change", handler)
handlers.delete(prop)
if (handlers.size == 0) {
this.#nested.delete(state)
}
}
// Anounces that a prop has changed
emit(prop, value) {
if (this.#options.defer ?? true) {
if (!this.#queue) {
this.#queue = []
queueMicrotask(() => {
this.dispatchEvent(new ChangeEvent(...this.#queue))
this.#queue = undefined
})
}
this.#queue.push([prop, value])
} else {
this.dispatchEvent(new ChangeEvent([prop, value]))
}
}
forward(property="value", fallback) {
if (!this.#forwardCache) this.#forwardCache = new Map()
const cached = this.#forwardCache.get(property)?.deref()
if (cached) {
return cached
} else {
const forwarded = new ForwardState(this, property, fallback)
const ref = new WeakRef(forwarded)
this.#forwardCache.set(property, ref)
forwardFinalizationRegistry.register(forwarded, [this.#forwardCache, property])
return forwarded
}
}
set(...args) {
if (args.length === 1) return this.set("value", ...args)
const [prop, value] = args
this.#target[prop] = value
}
get(...args) {
if (args.length === 0) return this.get("value")
const prop = args[0]
return this.#target[prop]
}
subscribe(prop, callback) {
if (!callback) return this.subscribe("value", prop)
const controller = new AbortController()
this.addEventListener("change", ({final}) => {
if (final.has(prop)) return callback(final.get(prop))
}, {signal: controller.signal})
callback(this.value)
return () => controller.abort()
}
// Backwards compatibility
get proxy() { return this.values }
}
const forwardFinalizationRegistry = new FinalizationRegistry(([cache, name]) => {
cache.remove(name)
})
export class ForwardState extends SimpleState {
#backend
#property
#fallback
constructor(backend, property, fallback) {
super()
this.#backend = backend
this.#property = property
this.#fallback = fallback
const ref = new WeakRef(this)
const abortController = new AbortController()
backend.addEventListener("change", event => {
const state = ref.deref()
if (state) {
const relevantChanges = event.changes
.filter(([name]) => name === property)
.map(([_, value]) => ["value", value])
if (relevantChanges.length > 0)
state.dispatchEvent(new ChangeEvent(...relevantChanges))
} else {
abortController.abort()
}
}, {signal: abortController.signal})
}
get value() { return this.#backend.values[this.#property] ?? this.#fallback }
set value(value) { this.#backend.values[this.#property] = value }
}
class StorageChangeEvent extends Event {
constructor(storage, key, value, targetState) {
super("storagechange")
this.storageArea = storage
this.key = key
this.newValue = value
this.targetState = targetState
}
}
export class StoredState extends State {
#storage
constructor(init, options={}) {
super({}, options)
this.#storage = options.storage ?? localStorage ?? new MapStorage()
// Initialise storage from defaults
for (const [prop, value] of Object.entries(init)) {
if (this.#storage[prop] === undefined)
this.set(prop, value)
}
// Emit change events for any changed keys
for (let i=0; i<this.#storage.length; i++) {
const key = this.#storage.key(i)
const value = this.#storage[key]
if (value !== JSON.stringify(init[key]))
this.emit(key, value)
}
// Listen for changes from other windows
const handler = event => {
if (event.targetState !== this && event.storageArea == this.#storage) {
this.emit(event.key, JSON.parse(event.newValue))
}
}
addEventListener("storage", handler)
addEventListener("storagechange", handler)
}
set(prop, value) {
const json = JSON.stringify(value)
dispatchEvent(new StorageChangeEvent(this.#storage, prop, json, this))
this.#storage[prop] = json
}
get(prop) {
const value = this.#storage[prop]
return value && JSON.parse(value)
}
}
const attributeObserver = new MutationObserver(mutations => {
for (const {type, target, attributeName: name} of mutations) {
if (type == "attributes") {
const next = target.getAttribute(name)
const camelName = kebabToCamel(name)
if (String(target.state.values[camelName]) !== next)
target.state.values[camelName] = next
}
}
})
export const component = (generator, name) => {
name = name ?? camelToKebab(generator.name)
const Element = class extends HTMLElement{
constructor() {
super()
this.state = new State(Object.fromEntries([...this.attributes].map(attribute => [kebabToCamel(attribute.name), attribute.value])))
this.state.addEventListener("change", event => {
for (const [name, value] of event.changes) {
const kebabName = camelToKebab(name)
if (this.getAttribute(kebabName) !== String(value))
this.setAttribute(kebabName, value)
}
})
attributeObserver.observe(this, {attributes: true})
this.replaceChildren(generator(this))
}
}
customElements.define(name, Element)
return Element;
}
class ComposedState extends SimpleState {
#func
#states
#options
constructor(func, options, ...states) {
super()
this.#func = func
this.#states = states
this.#options = options
const abortController = new AbortController()
abortRegistry.register(this, abortController)
const ref = new WeakRef(this)
states.forEach(state => {
state.addEventListener("change", event => {
const value = event.final.get("value")
if (value) ref.deref()?.scheduleUpdate()
}, {signal: abortController.signal})
})
this.update()
}
#microtaskQueued
scheduleUpdate() {
if (this.#options.defer) {
if (!this.#microtaskQueued) {
queueMicrotask(() => {
this.#microtaskQueued = false
this.update()
})
}
this.#microtaskQueued = true
} else {
this.update()
}
}
update() {
this.value = this.#func(...this.#states.map(state => state.value))
this.dispatchEvent(new ChangeEvent([["value", this.value]]))
}
}
export const compose = func => (...states) => new ComposedState(func, {defer: true}, ...states)
const eventName = "mutation"
class MutationEvent extends Event {
constructor() {
super(eventName, {bubbles: true})
}
}
const mutationObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
mutation.target.dispatchEvent(new MutationEvent())
}
})
export class DOMState extends SimpleState {
#target
#defer
#getValue
#equal
#old
#changedValue = false
constructor(target, options) {
super()
this.#defer = options.defer ?? false
this.#target = target
this.#getValue = options.get ?? (target => target.value)
this.#equal = options.equal ?? ((a, b) => a===b)
this.#old = this.#getValue(target)
const controller = new AbortController()
target.addEventListener(eventName, event=>{this.update(event)}, {signal: controller.signal})
abortRegistry.register(this, controller)
mutationObserver.observe(target, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
})
}
get value() { return this.#old }
update() {
const current = this.#getValue(this.#target)
if (this.#equal(this.#old, current)) return
this.#old = current
if (this.#defer) {
if (!this.#changedValue) {
queueMicrotask(() => {
this.#changedValue = false
this.dispatchEvent(new ChangeEvent(["value", this.#changedValue]))
})
this.#changedValue = current
}
} else {
this.dispatchEvent(new ChangeEvent(["value", current]))
}
}
}
export default State

47
styles.css Normal file
View File

@ -0,0 +1,47 @@
:root {
--corner-radius: 2px;
}
[contenteditable] { white-space: pre }
#elevator-pitch p, p.important {
font-size: 1.2em;
}
page-hero {
background-image:
radial-gradient(ellipse at 100% -40%, #fff0, #ccaaff06 70%, #fff0 70%),
radial-gradient(ellipse at -40% -20%, #fff0, #bb88ff02 60%, #fff0 60%),
radial-gradient(ellipse at 180% 180%, #fff0, #bb88ff02 60%, #fff0 60%),
linear-gradient(to top left, #a2f1, transparent),
linear-gradient(to top right, #c2d1, transparent);
&::after {
content: '';
display: block;
position: absolute;
bottom: 0;
height: .2rem;
left: 0;
right: 0;
background-image: linear-gradient(to right, #0660, #0f6, #0660);
background-image: linear-gradient(to right in hsl, #6060, #f0f, #6060);
filter: saturate(.4) opacity(.2);
}
}
skooma-showcase {
--padding: .6em;
border-radius: var(--corner-radius);
padding: var(--padding);
background-color: #fff1;
--hr-color: var(--primary-4);
tab-size: 3;
}
type-writer {
font-weight: bold;
&::after {
content: "|";
opacity: 0.5;
}
}