Compare commits
No commits in common. "main" and "page" have entirely different histories.
15 changed files with 678 additions and 1327 deletions
|
@ -1,2 +0,0 @@
|
|||
[*]
|
||||
indent_style = tab
|
|
@ -1,247 +0,0 @@
|
|||
# Rendering DOM nodes using `render.js`
|
||||
|
||||
```js
|
||||
import {html} from "nyooom/render.js"
|
||||
```
|
||||
|
||||
A functional-friendly helper library for procedural DOM generation and templating, with support for reactive state objects.
|
||||
|
||||
## Summary
|
||||
|
||||
```js
|
||||
html.button(
|
||||
"Click Me!",
|
||||
{
|
||||
class: "primary",
|
||||
click({target}) {
|
||||
console.log("User clicked", target)
|
||||
}
|
||||
},
|
||||
button => { console.log("Created", button) }
|
||||
)
|
||||
```
|
||||
|
||||
* elements as factory functions `content -> element`
|
||||
* content as arguments or in arrays
|
||||
* attributes in object arguments
|
||||
* style, dataset, shadow root, etc. as magic attributes
|
||||
* events as function attributes
|
||||
* initialisers as `any -> void` functions
|
||||
|
||||
## Interface / Examples
|
||||
|
||||
### Basic DOM generation
|
||||
|
||||
Accessing the `html` proxy with any string key returns a new node generator function.
|
||||
When called this function will generate a DOM node (HTML Tag).
|
||||
The name of the function becomes the tag name of the node.
|
||||
|
||||
```js
|
||||
html.div()
|
||||
```
|
||||
|
||||
Content and attributes can be set via the function arguments:
|
||||
Strings and DOM nodes are inserted as children, while other objects (except for some special cases) have their key-value pairs turned into attribute-value pairs on the
|
||||
|
||||
```js
|
||||
html.div("Big Text", {style: "font-size: 1.4em"})
|
||||
```
|
||||
|
||||
Arrays are iterated and their values treated as arguments.
|
||||
This works both for inserting children and setting attributes.
|
||||
|
||||
```js
|
||||
const content = [" ps: hi", {class: "title"}]
|
||||
html.h1({id: "main-heading"}, "Heading", content)
|
||||
```
|
||||
|
||||
Function arguments are treated differently depending on their length:]
|
||||
Functions with **no** named parameters are called, and their return value is then evaluated just like an argument to the generator.
|
||||
|
||||
All other functions are (immediately) called with the newly created node as their first and only argument.
|
||||
These can be used to initialise the new node in a point-free style.
|
||||
|
||||
```js
|
||||
const hello = () => html.bold("Hello, World!")
|
||||
const init = node => console.log("Initialising", node)
|
||||
html.div(hello, init)
|
||||
```
|
||||
|
||||
Nested tags can be generated with nested function calls.
|
||||
When properly formatted, this means simpler templates will have the same structure as if written in HTML (sans those pesky closing tags).
|
||||
|
||||
```js
|
||||
html.div(
|
||||
html.p(
|
||||
html.b("Bold Text")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Attribute Processing
|
||||
|
||||
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.")
|
||||
}})
|
||||
```
|
||||
|
||||
The special `style` property can be set to an object and its key/value pairs will be inserted as CSS properties on the element's `style` object.
|
||||
|
||||
```js
|
||||
const style = { color: "salmon" }
|
||||
html.span("Salmon", { style })
|
||||
```
|
||||
|
||||
The special property `shadowRoot` will attach a shadow-DOM to the element if none is present and append its content to the shadow root.
|
||||
Arrays are iterated over and their elements appended individually.
|
||||
|
||||
```js
|
||||
html.div({
|
||||
shadowRoot = ["Hello, ", html.b("World"), "!"]
|
||||
})
|
||||
```
|
||||
|
||||
The `dataset` property will add its key/value pairs to the new node's `dataset`,
|
||||
as a more convenient alternative to setting individual `data-` attributes.
|
||||
|
||||
```js
|
||||
const dataset = { foo: "bar" }
|
||||
const div = html.div({dataset})
|
||||
console.log(dataset.foo === div.dataset.foo)
|
||||
console.log(div.getAttribute("data-foo") === "bar")
|
||||
```
|
||||
|
||||
### Reactivity
|
||||
|
||||
Nyooom supports reactivity through a simple protocol:
|
||||
|
||||
Observable objects identify themselves with the `observable` attribute,
|
||||
which must return a truthy value.
|
||||
|
||||
Observables are expected to expose a `value` attribute that is both readable and writeable,
|
||||
and to emit a "change" event whenever its vale has changed.
|
||||
|
||||
Observables can be passed to nyooom's node functions as both
|
||||
attribute values (values in an object) or
|
||||
child elements (direct arguments or in an array).
|
||||
|
||||
#### Reactive Children
|
||||
|
||||
Passing an observable as a child element will attempt to insert its current
|
||||
value into the new node as if it was passed in directly, but will also hook into
|
||||
the observable to replace the value when the state changes.
|
||||
|
||||
```js
|
||||
const state = new Observable.value(0)
|
||||
|
||||
const button = html.button(state, {
|
||||
click(event) { state.value++ }
|
||||
})
|
||||
```
|
||||
|
||||
Note that to keep the replacement logic simple, it is not currently possible to
|
||||
insert use document fragments, as these could insert several top-level children
|
||||
into a component that would then all have to be replaced. When an observable
|
||||
contains or changes to a document fragment, nyooom will raise an error.
|
||||
|
||||
Before replacing an element, a `"replace"` event is emitted from the old
|
||||
element. This event bubbles and is cancelable, and can thus be used both to
|
||||
completely prevent the replacement according to custom logic, to alter or
|
||||
initialise the new element before it is inserted, or even to modify the old
|
||||
object instead of replacing it.
|
||||
|
||||
#### Reactive Attributes
|
||||
|
||||
Passing an observable as an object value will, likewise, treat its value as the
|
||||
attribute value, and update it whenever the state's value changes.
|
||||
|
||||
```js
|
||||
const state = new Observable.value(0)
|
||||
|
||||
const input_1 = html.input({ type: "number", value: state })
|
||||
const input_2 = html.input({ type: "number", value: state })
|
||||
```
|
||||
|
||||
## Helpers
|
||||
|
||||
### Empty Values
|
||||
|
||||
Nyooom will produce a warning when it encounters `undefined` as an argument to a
|
||||
node factory, and skip it.
|
||||
|
||||
When completely ommitting the argument would make code more complicated, the
|
||||
`nothing` export can be used instead, and will be completely ignored by nyooom:
|
||||
|
||||
```js
|
||||
import {html, nothing} from "nyooom/render"
|
||||
|
||||
const value = undefined
|
||||
|
||||
html.div(value ? html.p(value) : nothing) // <div></div>
|
||||
```
|
||||
|
||||
### Event Handlers
|
||||
|
||||
Event handlers often need to call `preventDefault()` and `stopPropagation()` on
|
||||
the event. To save a bit of typing, the wrapper functions `noDefault` and
|
||||
`noPropagation` take a function `event => void` and wrap it in a function that
|
||||
first calls the corresponding method on the event:
|
||||
|
||||
```js
|
||||
html.form(
|
||||
html.button("Click me!", click: noDefault(() => console.log("Clicked!")))
|
||||
)
|
||||
```
|
||||
|
||||
### DOM-Fragments
|
||||
|
||||
The `fragment` helper function collects a list of DOM nodes from its arguments
|
||||
into a new document fragment, so they can more easily be processed as a group
|
||||
and inserted elsewhere.
|
||||
|
||||
```js
|
||||
import {fragment} from "nyooom/render"
|
||||
|
||||
const f = fragment(html.p("paragraph 1"), html.p("paragraph 1"))
|
||||
document.body.append(f.cloneNode(true))
|
||||
```
|
||||
|
||||
### Text
|
||||
|
||||
The `text` helper function operates in two modes: When a string is provided, it
|
||||
outputs a `TextNode`. This acts the same as `document.createTextNode`, except it
|
||||
won't error for `undefined` and instead simply return an empty text node.
|
||||
|
||||
When used as a template literal, it will return a document template containing a
|
||||
list of text nodes. HTML-Elements can be interpolated into these fragments and
|
||||
will be inserted into the template.
|
||||
|
||||
```js
|
||||
import {text} from "nyooom/render"
|
||||
|
||||
const t = text`Hello, ${html.b("World")}!`
|
||||
```
|
||||
|
||||
## Extending
|
||||
|
||||
The Nyooom renderer is built in a modular way that allows for easy extending:
|
||||
|
||||
The most generic functionality like the `proxy()` method is implemented in the
|
||||
`Renderer` class.
|
||||
|
||||
This is extended by the `DomRenderer` class which defines how
|
||||
elements are created inside the browser or a compatible DOM implementation.
|
||||
|
||||
The DomHtmlRenderer defines a `camelCase` to `snake-case` conversion for node
|
||||
names and a list of magic node names to be handled differently.
|
||||
|
||||
The `DomSvgRenderer` creates DOM nodes in the SVG namespace.
|
74
domProxy.js
74
domProxy.js
|
@ -1,74 +0,0 @@
|
|||
export const domArray = (methods, extra) => {
|
||||
if (extra) return domArray(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.call(child)
|
||||
}
|
||||
}
|
||||
} else if (prop.match?.call(prop, /^[0-9]+$/)) {
|
||||
const child = target.children[prop]
|
||||
if (child) return methods.get.call(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.call(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.call(element) !== value)
|
||||
methods.set.call(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 => {
|
||||
if (!(element instanceof Element)) throw(new Error("Creating domArray on non-element"))
|
||||
return new Proxy(element, traps)
|
||||
}
|
||||
}
|
||||
|
||||
export const meta = (element=document.head) => new Proxy(element, {
|
||||
get: (target, name) => target.querySelector(`meta[name="${name}"]`)?.content,
|
||||
set: (target, name, value) => {
|
||||
let meta = target.querySelector(`meta[name="${name}"]`)
|
||||
if (!meta) {
|
||||
meta = document.createElement("meta")
|
||||
meta.name = name
|
||||
target.append(meta)
|
||||
}
|
||||
meta.content = value
|
||||
}
|
||||
})
|
|
@ -1,8 +0,0 @@
|
|||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"nyooom/render": "./render.js",
|
||||
"nyooom/observable": "./observable.js"
|
||||
}
|
||||
}
|
||||
</script>
|
501
index.html
Normal file
501
index.html
Normal file
|
@ -0,0 +1,501 @@
|
|||
<html theme=dark>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"nyooom/render": "https://cdn.jsdelivr.net/gh/darkwiiplayer/nyooom@d457c38/render.js",
|
||||
"nyooom/observable": "https://cdn.jsdelivr.net/gh/darkwiiplayer/nyooom@d457c38/observable.js",
|
||||
"nyooom/showcase": "./js/nyooom-showcase.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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);
|
||||
/* url('https://cdn.jsdelivr.net/gh/darkwiiplayer/css@main/schemes/talia.css') */
|
||||
@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 "nyooom/showcase"
|
||||
</script>
|
||||
|
||||
<a name=top></a>
|
||||
|
||||
<header role="navigation" class="fixed">
|
||||
<nav class="bar">
|
||||
<ul>
|
||||
<li><a href="#top">Top</a></li>
|
||||
<li><a href="https://git.but.gay/darkwiiplayer/nyooom">Git</a></li>
|
||||
<li><a href="https://github.com/darkwiiplayer/nyooom">Github</a></li>
|
||||
<li><a href="https://www.npmjs.com/package/nyooom">npm</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<img alt="" class="jsdelivr-badge" src="https://data.jsdelivr.com/v1/package/npm/nyooom/badge">
|
||||
</header>
|
||||
|
||||
<page-hero role="banner" cover=60>
|
||||
<!-- <img src="https://picsum.photos/1920/1080"> -->
|
||||
<hgroup>
|
||||
<h1 style="font-size: 6vw">Nyooom</span></h1>
|
||||
<code lang="js">import { html } from "nyooom/render"</code>
|
||||
<p>
|
||||
A new way of building
|
||||
<type-writer loop>
|
||||
<span>HTML</span>
|
||||
<template>SVG</template>
|
||||
<template>templates</template>
|
||||
<template>components</template>
|
||||
<template>applications</template>
|
||||
</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>
|
||||
|
||||
<main style="margin-top: 0">
|
||||
<section id="elevator-pitch" class="box">
|
||||
<div class="content-width" style="padding: 2em 0">
|
||||
<h2>Elevator Pitch</h2>
|
||||
|
||||
<p class="big">
|
||||
Nyooom combines <em>easy</em> and <em>powerful</em> HTML generation with optional <em>reactivity</em>, all in plain JavaScript.
|
||||
</p>
|
||||
|
||||
<nyooom-showcase preview=false>
|
||||
<code><div contenteditable="false">return html.p(
|
||||
"Text Node",
|
||||
html.br(),
|
||||
html.b("HTML Node"),
|
||||
{style:{color:"salmon"}},
|
||||
)</div></code>
|
||||
</nyooom-showcase>
|
||||
|
||||
<vertical-spacer></vertical-spacer>
|
||||
|
||||
<p>
|
||||
Nyooom is <em>small</em> enough that you can use it even if you only need it in a single function,
|
||||
<em>obvious</em> enough to figure out what's going on even if you've never used it,
|
||||
<em>powerful</em> enough that you won't <i>need</i> another framework and
|
||||
<em>flexible</em> enough to let you use one regardless.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Sounds good? Continue reading to find out how to try it yourself!
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="getting-started" class="content-width">
|
||||
<h2>Getting Started</h2>
|
||||
|
||||
<section>
|
||||
<p>
|
||||
Trying out nyooom is super easy:
|
||||
</p>
|
||||
|
||||
<pre class="box"><code lang=js>import {html} from "https://cdn.jsdelivr.net/npm/nyooom/nyooom.js"</code></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p>
|
||||
Calling any method on the <code>html</code> export will generate an HTML node.<br>
|
||||
Passing in arguments is how you add children, set attributes and even do more advanced things.
|
||||
</p>
|
||||
|
||||
<nyooom-showcase preview="false">
|
||||
<code>
|
||||
<div contenteditable="false">return html.div("content")</div>
|
||||
</code>
|
||||
</nyooom-showcase>
|
||||
|
||||
<p>
|
||||
Using the <code>component</code> helper from the <code>nyooom/observable</code> module
|
||||
makes it easier to see results on your page:
|
||||
Just pass it a function that returns some DOM nodes, and it'll register a custom element for you.
|
||||
</p>
|
||||
|
||||
<pre class="box"><code lang=js>import {component} from "https://cdn.jsdelivr.net/npm/nyooom/observable.js"
|
||||
const myComponent = () => html.div("Rendered Component")
|
||||
component(myComponent) //Registers it as <my-component></code></pre>
|
||||
|
||||
<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">
|
||||
<h2>Basic DOM generation</h2>
|
||||
|
||||
<section>
|
||||
<p>
|
||||
Content can be added to a node by simply passing it as arguments to the function.
|
||||
This works for strings, HTML elements, functions and even observable state containers.
|
||||
</p>
|
||||
|
||||
<nyooom-showcase preview="false">
|
||||
<code>
|
||||
<div contenteditable="false">return html.span(
|
||||
"Nyooom ",
|
||||
html.u(
|
||||
"is ",
|
||||
html.b("cool")
|
||||
)
|
||||
)</div>
|
||||
</code>
|
||||
</nyooom-showcase>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p>
|
||||
Adding attributes is just as easy: Just pass an object into the function.
|
||||
If the value is an array, it'll get joined with spaces.
|
||||
</p>
|
||||
|
||||
<nyooom-showcase preview="false">
|
||||
<code>
|
||||
<div contenteditable="false">return html.span({
|
||||
id: "warning",
|
||||
class: ["danger", "bold"],
|
||||
})</div>
|
||||
</code>
|
||||
</nyooom-showcase>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p>
|
||||
Event listeners work just like attribute, except the value is a function:
|
||||
</p>
|
||||
|
||||
<nyooom-showcase code="false">
|
||||
<code>
|
||||
<div contenteditable="false">return html.button("Click Me!", {
|
||||
click: event => {
|
||||
alert("Button clicked :3")
|
||||
}
|
||||
})</div>
|
||||
</code>
|
||||
</nyooom-showcase>
|
||||
|
||||
<section>
|
||||
<p class="big">
|
||||
Some "attributes" are handled in special ways to make things easier:
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<code>style</code> sets the inline styles of the node.
|
||||
</p>
|
||||
<p>
|
||||
<code>camelCase</code> names will be converted to <code>kebab-case</code> and values
|
||||
will be converted to strings.
|
||||
</p>
|
||||
|
||||
<nyooom-showcase>
|
||||
<code>
|
||||
<div contenteditable="false">return html.div(
|
||||
{ style: {
|
||||
color: "salmon"
|
||||
}},
|
||||
html.span("Big Salmon",
|
||||
{ style: {
|
||||
fontSize: "1.4em"
|
||||
}}
|
||||
)
|
||||
)</div>
|
||||
</code>
|
||||
</nyooom-showcase>
|
||||
|
||||
<p>
|
||||
<code>dataset</code> converts its key-value pairs into <code>data-</code> attributes.
|
||||
</p>
|
||||
|
||||
<nyooom-showcase preview=false>
|
||||
<code>
|
||||
<div contenteditable="false">return html.span({
|
||||
dataset: {
|
||||
from: "Fire",
|
||||
to: "Heat",
|
||||
snakeCase: "converted"
|
||||
}
|
||||
}, "Heat from Fire")</div>
|
||||
</code>
|
||||
</nyooom-showcase>
|
||||
|
||||
<p>
|
||||
<code>shadowRoot</code> will attach a shadow DOM to the newly created node.
|
||||
</p>
|
||||
|
||||
<nyooom-showcase code=false>
|
||||
<code>
|
||||
<div contenteditable="false">return html.div({
|
||||
shadowRoot: [
|
||||
html.p("Shadow DOM text"),
|
||||
"Arrays work too :)"
|
||||
]
|
||||
}, "Light DOM text")</div>
|
||||
</code>
|
||||
</nyooom-showcase>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="content-width">
|
||||
|
||||
<h2>It's just JavaScript</h2>
|
||||
|
||||
<p>Nyooom is entirely implemented as a light-weight JavaScript library.</p>
|
||||
|
||||
<p>
|
||||
This means that there is no new syntax for things you already know how to do.
|
||||
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,
|
||||
common structures can be extracted into function and even passed around as arguments,
|
||||
and the syntax is just the boring old javascript that most tools already understand.
|
||||
</p>
|
||||
|
||||
<nyooom-showcase code="false">
|
||||
<code>
|
||||
<div contenteditable="false">return html.ul(
|
||||
["u", "b", "i"].map(
|
||||
type => html.li(html[type](
|
||||
`<${type}> element`
|
||||
))
|
||||
)
|
||||
)</div>
|
||||
</code>
|
||||
</nyooom-showcase>
|
||||
</section>
|
||||
|
||||
<section class="content-width">
|
||||
<h2>State changes in Nyooom</h2>
|
||||
|
||||
<pre class="box"><code lang=js>import {ObservableObject} from 'nyooom/observable'</code></pre>
|
||||
|
||||
<p>
|
||||
Nyooom offers a series of state management classes it calls "observables".
|
||||
Their purpose is to store one or more values and emit events when they are changed.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The <code>render.js</code> library handles observables by setting DOM nodes up to
|
||||
get updated when the observable emits a change event. Neihter module depends on the other
|
||||
and they use a common protocol to work together.
|
||||
</p>
|
||||
|
||||
<nyooom-showcase code="false">
|
||||
<code>
|
||||
<div contenteditable="false">const counter = new State(0)
|
||||
|
||||
return html.buttonGroup(
|
||||
html.button(
|
||||
"Click Me! ",
|
||||
html.span(
|
||||
counter.compose(val => val ? val : "")
|
||||
),
|
||||
{
|
||||
click() {
|
||||
counter.value += 1
|
||||
}
|
||||
}
|
||||
),
|
||||
html.button(
|
||||
"Clear", {
|
||||
click() {
|
||||
counter.value = 0
|
||||
}
|
||||
}
|
||||
)
|
||||
)</div>
|
||||
</code>
|
||||
</nyooom-showcase>
|
||||
</section>
|
||||
|
||||
<section class="content-width">
|
||||
|
||||
<h2>Obligatory Todo list</h2>
|
||||
|
||||
<p>
|
||||
This simple ToDo component uses nothing more than Nyooom and
|
||||
the stylesheets already on this page to save some boilerplate.
|
||||
</p>
|
||||
|
||||
<nyooom-showcase code="false">
|
||||
<code>
|
||||
<div contenteditable="false">const task = value =>
|
||||
html.flexRow(
|
||||
{ class: "todo", gap: 1 },
|
||||
value,
|
||||
html.span("[x]", {
|
||||
style: { cursor: "pointer" },
|
||||
click(event) {
|
||||
event
|
||||
.target
|
||||
.closest(".todo")
|
||||
.remove()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
let todo, input
|
||||
return todo = html.flexColumn(
|
||||
{gap: 1},
|
||||
input = html.input({
|
||||
type: "text",
|
||||
placeholder:
|
||||
"Do some stuff",
|
||||
}),
|
||||
html.button("Add Task",
|
||||
{
|
||||
click() {
|
||||
todo.append(task(
|
||||
input.value ||
|
||||
input.placeholder
|
||||
))
|
||||
}
|
||||
})
|
||||
)</div>
|
||||
</code>
|
||||
</nyooom-showcase>
|
||||
</section>
|
||||
|
||||
<section id="nyooom-explained" class="content-width">
|
||||
<h2>Nyooom Explained</h2>
|
||||
|
||||
<h3>A bit of boring history</h3>
|
||||
|
||||
<p>
|
||||
The <code>nyooom/render.js</code> module traces its basic idea back to a <a href="https://github.com/darkwiiplayer/skooma">Lua module</a>.
|
||||
The basic mechanism is the exact same, as the languages are very similar;
|
||||
with the noteable difference that the Lua version outputs text and uses a very
|
||||
lightweight DOM-like data-structure as an intermediary representation.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Porting the library code to JavaScript was an obvious choice, given how similar both
|
||||
languages are in how they handle functions and metaprogramming.
|
||||
The concept worked well to generate HTML to send to the client,
|
||||
so it would work equally well to generate DOM nodes directly in the browser.
|
||||
And the obvious advantages over a server-side implementation is that things like event listeners could be attached directly.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
One design goal while further expanding the capabilities of nyooom has been to preserve this original use-case.
|
||||
The library is <i>small enough</i> that pulling it into a project just to generate a few nested DOM structures
|
||||
here and there isn't an unjustifiable waste of application size.
|
||||
This is also why the <code>render</code> module does not depend on the <code>observable</code>
|
||||
module, and has as little code as it can get away with to enable interoperability.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The later addition of the <code>Obervable</code> module, and the ability of nyooom to detect state objects and
|
||||
handle them specially to enable reactivity is what truly enables the library to be used on its own to build
|
||||
simple and even moderately complex interactive components.
|
||||
<br>
|
||||
And with most of this extra complexity existing in a separate file,
|
||||
the size impact on the core nyooom library is very minimal, when
|
||||
reactivity is not wanted, or an alternative mechanism for reactivity
|
||||
is preferred.
|
||||
</p>
|
||||
|
||||
<h3>Implementation</h3>
|
||||
|
||||
<p>
|
||||
Nyooom is based on a convenient wrapper around <code>document.createElement</code>
|
||||
made even more convenient by a <code>Proxy</code> to generate & populate DOM nodes.
|
||||
</p>
|
||||
|
||||
<h3>Rationale & design principles</h3>
|
||||
|
||||
<p>
|
||||
The design of nyooom is based on the following assumption about web applications:
|
||||
</p>
|
||||
<blockquote>
|
||||
Most web application code can be modeled with simple abstractions.<br>
|
||||
The few cases that can't tend to be too complex for any general-purpose abstraction anyway.
|
||||
</blockquote>
|
||||
<p>
|
||||
Leading to a framework that focuses on the former group,
|
||||
while giving developers the tools to handle the tricky bits without having to hack themselves out of the framework first.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
One consideration that sets nyooom apart from other alternatives,
|
||||
is the emphasis on being both easy to adopt, integrate, and even abandon.
|
||||
</p>
|
||||
<p>
|
||||
While nyooom provides some level of abstraction over native browser APIs, everything done via nyooom is
|
||||
still easy to mentally map onto the underlying APIs. This reduces the mental load of translating what the
|
||||
code says, and what is happening to the website, creating a common mental model with vanilla javascript,
|
||||
as well as other non-magic libraries and micro-frameworks.
|
||||
</p>
|
||||
<p>
|
||||
This, in practice, means that adding some code that uses nyooom to an existing project, integrating it with
|
||||
other technologies, and even removing it in parts or in whole from a project that has outgrown it, or from performance-critical
|
||||
parts of an application that need even the last drop of performance squeezed out of the browser are all very
|
||||
simple to achieve.
|
||||
</p>
|
||||
<p>
|
||||
Even taking out certain aspects like reactivity, while still using nyooom to generate DOM nodes that get manipulated
|
||||
manually in response to complex state-changes is as easy as replacing the state objects with primitive values
|
||||
and writing the custom code to take their place.
|
||||
</p>
|
||||
<p>
|
||||
Where other frameworks suck in their stomach and do their best to fit every use-case you could have,
|
||||
nyooom is happy to just step aside and make space for more specialised tools.
|
||||
<!-- nyooom: the framework that promotes body-acceptance -->
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="inset box">
|
||||
<flex-row gap=3>
|
||||
<table>
|
||||
<caption style="padding: 0">Feedback</caption>
|
||||
<tr>
|
||||
<th scope="col">Fediverse</th>
|
||||
<td><a href="https://tech.lgbt/@darkwiiplayer">@darkwiiplayer@tech.lgbt</a></td></tr>
|
||||
</table>
|
||||
</flex-row>
|
||||
</footer>
|
||||
</htmlt>
|
108
js/nyooom-showcase.js
Normal file
108
js/nyooom-showcase.js
Normal file
|
@ -0,0 +1,108 @@
|
|||
import {html,nothing} from "nyooom/render"
|
||||
import {ObservableValue,ObservableObject} from "nyooom/observable"
|
||||
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 NyooomShowcase 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.classList.add("box")
|
||||
this.shadowRoot.replaceChildren(
|
||||
html.slot(),
|
||||
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, nothing, ObservableValue)
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"checkJs": true,
|
||||
"target": "es2021"
|
||||
}
|
||||
}
|
22
license.md
22
license.md
|
@ -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.
|
23
mini.js
23
mini.js
|
@ -1,23 +0,0 @@
|
|||
/**
|
||||
* @param {String} name
|
||||
* @return {String}
|
||||
*/
|
||||
const snakeToHTML = name => name.replace(/([A-Z])/g, "-$1").replace(/^-/, "").toLowerCase()
|
||||
|
||||
// @ts-ignore
|
||||
export default new Proxy(/** @type {Object<string,(...args: any)=>HTMLElement>} */(document), {
|
||||
/** @param {string} tag */
|
||||
get: (_, tag) => /** @param {any[]} args */ (...args) => {
|
||||
let node = document.createElement(snakeToHTML(tag))
|
||||
for (const arg of args) {
|
||||
if (arg instanceof HTMLElement) {
|
||||
node.append(arg)
|
||||
} else if (arg instanceof Object) {
|
||||
for (let key in arg) {
|
||||
node[key] = arg[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return node
|
||||
}
|
||||
})
|
398
observable.js
398
observable.js
|
@ -1,398 +0,0 @@
|
|||
/** @type FinalizationRegistry<AbortController> */
|
||||
const abortRegistry = new FinalizationRegistry(controller => controller.abort())
|
||||
|
||||
/** @param {String} string */
|
||||
const camelToKebab = string => string.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`)
|
||||
/** @param {String} string */
|
||||
const kebabToCamel = string => string.replace(/([a-z])-([a-z])/g, (_, a, b) => a+b.toUpperCase())
|
||||
|
||||
const identity = object=>object
|
||||
|
||||
const target = Symbol("Proxy Target")
|
||||
|
||||
/* Custom Event Classes */
|
||||
|
||||
/**
|
||||
* @typedef {Object} Change
|
||||
* @property {string} property
|
||||
* @property {any} from
|
||||
* @property {any} to
|
||||
* @property {boolean} mutation - The change happened inside the value without a new assignment
|
||||
*/
|
||||
|
||||
/** Event fired for every change before the internal state has been updated that can be canceled. */
|
||||
export class ChangeEvent extends Event {
|
||||
/** @param {Change} change */
|
||||
constructor(change) {
|
||||
super('change', {cancelable: true})
|
||||
this.change = Object.freeze(change)
|
||||
}
|
||||
}
|
||||
|
||||
/** Event fired for one or more changed values after the internal state has been updated. */
|
||||
export class ChangedEvent extends Event {
|
||||
/** @type {any} */
|
||||
#final
|
||||
/** @type {any} */
|
||||
#values
|
||||
|
||||
/** @param {Change[]} changes */
|
||||
constructor(...changes) {
|
||||
super('changed')
|
||||
this.changes = changes
|
||||
}
|
||||
|
||||
get values() {
|
||||
if (!this.#values) {
|
||||
const values = new Map()
|
||||
for (const {property, from, to} of this.changes) {
|
||||
let list = values.get(property)
|
||||
if (!list) {
|
||||
list = [from]
|
||||
values.set(property, list)
|
||||
}
|
||||
list.push(to)
|
||||
}
|
||||
this.#values = values
|
||||
}
|
||||
return this.#values
|
||||
}
|
||||
|
||||
get final() {
|
||||
if (!this.#final) {
|
||||
this.#final = new Map()
|
||||
for (const [property, list] of this.values) {
|
||||
if (list[0] !== list[list.length-1]) {
|
||||
this.#final.set(property, list[list.length-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.#final
|
||||
}
|
||||
}
|
||||
|
||||
export class Observable extends EventTarget {
|
||||
#synchronous
|
||||
/** @type Change[]> */
|
||||
#queue
|
||||
#abortController = new AbortController
|
||||
|
||||
#ref = new WeakRef(this)
|
||||
get ref() { return this.#ref }
|
||||
|
||||
constructor({synchronous=false}={}) {
|
||||
super()
|
||||
Object.defineProperty(this, "observable", {value: true, configurable: false, writable: false})
|
||||
|
||||
if (this.constructor === Observable) {
|
||||
throw new TypeError("Cannot instantiate abstract class")
|
||||
}
|
||||
this.#synchronous = !!synchronous
|
||||
abortRegistry.register(this, this.#abortController)
|
||||
|
||||
this.proxy = new Proxy(this.constructor.prototype.proxy, {
|
||||
get: (target, prop) => target.call(this, prop)
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {Change[]} changes */
|
||||
emit(...changes) {
|
||||
this.dispatchEvent(new ChangedEvent(...changes))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} prop
|
||||
*/
|
||||
proxy(prop, {get=undefined, set=undefined, ...options}={}) {
|
||||
const proxy = new ProxiedObservableValue(this, prop, options)
|
||||
if (get) proxy.get = get
|
||||
if (set) proxy.set = set
|
||||
return proxy
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} prop
|
||||
* @param {function(any):void} callback
|
||||
*/
|
||||
subscribe(prop, callback) {
|
||||
const controller = new AbortController()
|
||||
// @ts-ignore
|
||||
this.addEventListener("change", ({final}) => {
|
||||
if (final.has(prop)) return callback(final.get(prop))
|
||||
}, {signal: controller.signal})
|
||||
|
||||
callback(this[prop])
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
/** Queues up a change event
|
||||
* @param {string} property - Name of the changed property
|
||||
* @param {any} from
|
||||
* @param {any} to
|
||||
* @param {boolean} mutation - whether a change was an assignment or a mutation (nested change)
|
||||
*/
|
||||
enqueue(property, from, to, mutation=false) {
|
||||
const change = {property, from, to, mutation}
|
||||
if (!this.dispatchEvent(new ChangeEvent(change))) return false
|
||||
|
||||
if (!this.synchronous) {
|
||||
if (!this.#queue) {
|
||||
this.#queue = []
|
||||
queueMicrotask(() => {
|
||||
this.emit(...this.#queue)
|
||||
this.#queue = undefined
|
||||
})
|
||||
}
|
||||
this.#queue.push(change)
|
||||
} else {
|
||||
this.emit(change)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
get signal() { return this.#abortController.signal }
|
||||
get synchronous() { return this.#synchronous }
|
||||
|
||||
get changesQueued() { return Boolean(this.#queue) }
|
||||
}
|
||||
|
||||
export class ObservableObject extends Observable {
|
||||
/**
|
||||
* @param {Object} target
|
||||
* @param {Object} options
|
||||
*/
|
||||
constructor(target={}, {shallow=true, ...options}={}) {
|
||||
super(options)
|
||||
Object.defineProperty(this, "target", target)
|
||||
this.values = new Proxy(target, {
|
||||
/**
|
||||
* @param {Object} target
|
||||
* @param {String} prop
|
||||
* @param {any} value
|
||||
*/
|
||||
set: (target, prop, value) => {
|
||||
const old = target[prop]
|
||||
if (old === value) {
|
||||
return true
|
||||
} else {
|
||||
if (this.enqueue(prop, old, value)) {
|
||||
if (!shallow) {
|
||||
if (old instanceof Observable) this.disown(prop, old)
|
||||
if (value instanceof Observable) this.adopt(prop, value)
|
||||
}
|
||||
target[prop] = value
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Object} target
|
||||
* @param {String} prop
|
||||
*/
|
||||
get: (target, prop) => target[prop],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} prop
|
||||
* @param {Object} options
|
||||
*/
|
||||
proxy(prop, {get=undefined, set=undefined, ...options}={}) {
|
||||
const proxy = new ProxiedObservableValue(this, prop, {values: this.values, ...options})
|
||||
if (get) proxy.get = get
|
||||
if (set) proxy.set = set
|
||||
return proxy
|
||||
}
|
||||
|
||||
/** @type Map<Observable, Map<String, Function>> */
|
||||
#nested = new Map()
|
||||
|
||||
/** Adopts an observable to be notified of its changes
|
||||
* @param {string} prop
|
||||
* @param {Observable} observable
|
||||
*/
|
||||
adopt(prop, observable) {
|
||||
let handlers = this.#nested.get(observable)
|
||||
if (!handlers) {
|
||||
// Actual adoption
|
||||
handlers = new Map()
|
||||
this.#nested.set(observable, handlers)
|
||||
}
|
||||
const ref = this.ref
|
||||
const handler = () => ref.deref()?.emit(prop, observable, observable, {observable: true})
|
||||
|
||||
handlers.set(prop, handler)
|
||||
observable.addEventListener("changed", handler, {signal: this.signal})
|
||||
}
|
||||
|
||||
/** Undoes the adoption of a nested observable, cancelling the associated event hook
|
||||
* @param {string} prop
|
||||
* @param {Observable} observable
|
||||
*/
|
||||
disown(prop, observable) {
|
||||
const handlers = this.#nested.get(observable)
|
||||
const handler = handlers.get(prop)
|
||||
observable.removeEventListener("changed", handler)
|
||||
handlers.delete(prop)
|
||||
if (handlers.size == 0) {
|
||||
this.#nested.delete(observable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ObservableValue extends Observable {
|
||||
#value
|
||||
|
||||
/**
|
||||
* @param {any} value
|
||||
* @param {Object} options
|
||||
*/
|
||||
constructor(value, options) {
|
||||
super(options)
|
||||
this.#value = value
|
||||
}
|
||||
|
||||
get value() { return this.#value }
|
||||
set value(value) {
|
||||
if (this.enqueue("value", this.#value, value)) {
|
||||
this.#value = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(value: any) => any} func
|
||||
*/
|
||||
transform(func) {
|
||||
return new Composition(func, {}, this)
|
||||
}
|
||||
|
||||
proxy(methods) {
|
||||
}
|
||||
}
|
||||
|
||||
class ProxiedObservableValue extends ObservableValue {
|
||||
#values
|
||||
#prop
|
||||
|
||||
/**
|
||||
* @param {Observable} backend
|
||||
* @param {string} prop
|
||||
*/
|
||||
constructor(backend, prop, {values=backend, ...options}={}) {
|
||||
super(options)
|
||||
this.#values = values
|
||||
this.#prop = prop
|
||||
|
||||
const ref = this.ref
|
||||
backend.addEventListener("change", event => {
|
||||
const {property, from, to, ...rest} = event.change
|
||||
if (property == this.#prop) {
|
||||
ref.deref()?.enqueue({
|
||||
property,
|
||||
from: this.get(from),
|
||||
to: this.get(to),
|
||||
...rest
|
||||
})
|
||||
}
|
||||
}, { signal: this.signal })
|
||||
}
|
||||
|
||||
get = identity
|
||||
set = identity
|
||||
|
||||
get value() { return this.get(this.#values[this.#prop]) }
|
||||
set value(value) { this.#values[this.#prop] = this.set(value) }
|
||||
}
|
||||
|
||||
const attributeObserver = new MutationObserver(mutations => {
|
||||
for (const {type, target, attributeName: name} of mutations) {
|
||||
if (type == "attributes" && target instanceof HTMLElement) {
|
||||
const next = target.getAttribute(name)
|
||||
const camelName = kebabToCamel(name)
|
||||
if (String(target.state.values[camelName]) !== next)
|
||||
target.state.values[camelName] = next
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const component = (name, generator, methods) => {
|
||||
if (typeof name === "function") {
|
||||
methods = generator
|
||||
generator = name
|
||||
name = camelToKebab(generator.name)
|
||||
}
|
||||
const jsName = kebabToCamel(name)
|
||||
component[jsName] = class extends HTMLElement{
|
||||
/** @type {ObservableObject} */
|
||||
state
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
const target = Object.fromEntries([...this.attributes].map(attribute => [kebabToCamel(attribute.name), attribute.value]))
|
||||
this.state = new ObservableObject(target)
|
||||
this.state.addEventListener("changed", event => {
|
||||
if (event instanceof ChangedEvent)
|
||||
for (const {property, to: value} of event.changes) {
|
||||
const kebabName = camelToKebab(property)
|
||||
if (this.getAttribute(kebabName) !== String(value))
|
||||
this.setAttribute(kebabName, value)
|
||||
}
|
||||
})
|
||||
attributeObserver.observe(this, {attributes: true})
|
||||
const content = generator.call(this, this.state)
|
||||
if (content) this.replaceChildren(content)
|
||||
}
|
||||
}
|
||||
const element = component[jsName]
|
||||
if (methods) {
|
||||
Object.defineProperties(element.prototype, Object.getOwnPropertyDescriptors(methods))
|
||||
}
|
||||
customElements.define(name, element)
|
||||
return element;
|
||||
}
|
||||
|
||||
class Composition extends ObservableValue {
|
||||
#func
|
||||
#states
|
||||
|
||||
/**
|
||||
* @param {(...values: any[]) => any} func
|
||||
* @param {Object} options
|
||||
* @param {Observable[]} states
|
||||
*/
|
||||
constructor(func, options, ...obesrvables) {
|
||||
super(options)
|
||||
|
||||
this.#func = func
|
||||
this.#states = obesrvables
|
||||
|
||||
const abortController = new AbortController()
|
||||
abortRegistry.register(this, abortController)
|
||||
const ref = new WeakRef(this)
|
||||
|
||||
obesrvables.forEach(state => {
|
||||
state.addEventListener("changed", () => {
|
||||
ref.deref()?.update()
|
||||
}, {signal: abortController.signal})
|
||||
})
|
||||
|
||||
this.update()
|
||||
}
|
||||
|
||||
update() {
|
||||
const value = this.#func(...this.#states.map(state => state.value))
|
||||
this.value = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Function} func
|
||||
*/
|
||||
export const compose = func =>
|
||||
/**
|
||||
* @param {Observable[]} observables
|
||||
*/
|
||||
(...observables) =>
|
||||
new Composition(func, {}, ...observables)
|
16
package.json
16
package.json
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"name": "nyooom",
|
||||
"version": "0.0.2",
|
||||
"author": "darkwiiplayer",
|
||||
"license": "Unlicense",
|
||||
"exports": {
|
||||
"./render": "./render.js"
|
||||
},
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.but.gay/darkwiiplayer/nyooom"
|
||||
},
|
||||
"homepage": "https://darkwiiplayer.github.io/nyooom",
|
||||
"keywords": []
|
||||
}
|
84
readme.md
84
readme.md
|
@ -1,84 +0,0 @@
|
|||
# Nyooom
|
||||
|
||||
```js
|
||||
import {html} from "nyooom/render"
|
||||
import {ObservableValue} from "nyooom/observable"
|
||||
|
||||
const text = new ObservableValue("Nyooom is cool")
|
||||
setTimeout(() => {text.value = "Nyooom 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!") }})
|
||||
))
|
||||
```
|
||||
|
||||
## Goals
|
||||
|
||||
> Arrakis teaches the attitude of the knife - chopping off what's incomplete and
|
||||
> saying: "Now, it's complete because it's ended here."
|
||||
>
|
||||
> — Frank Herbert, Dune
|
||||
|
||||
Nyooom aims to offer as much convenienve as possible within the following
|
||||
constraints:
|
||||
|
||||
1. Small, independent modules that can also work on their own
|
||||
1. Code should be easy to figure out by someone who doesn't actiely use nyooom
|
||||
1. Easy to gradually introduce and remove rather than forcing big re-writes
|
||||
1. Flexible, hackable and easy to audit
|
||||
|
||||
## Importmaps
|
||||
|
||||
The included file `importmaps.html` can be used as a starting point for
|
||||
importing `nyooom` via importmaps in a minimal environment. Search-and-replace
|
||||
`./` to wherever the library should be loaded from if necessary.
|
||||
|
||||
## 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"),
|
||||
]))
|
||||
```
|
18
ref.js
18
ref.js
|
@ -1,18 +0,0 @@
|
|||
/** A reference to a reactive element that follows it around through changes */
|
||||
export class Ref {
|
||||
#current
|
||||
/** @param {Element|Text} target A reactive element to follow */
|
||||
constructor(target) {
|
||||
this.#current = target
|
||||
this.follow(target)
|
||||
}
|
||||
|
||||
follow(target) {
|
||||
target.addEventListener("replaced", ({next}) => {
|
||||
this.#current = next
|
||||
this.follow(next)
|
||||
})
|
||||
}
|
||||
|
||||
deref() { return this.#current }
|
||||
}
|
429
render.js
429
render.js
|
@ -1,429 +0,0 @@
|
|||
// Keep a referee alive until a referrer is collected
|
||||
const weakReferences = new WeakMap()
|
||||
|
||||
/** Keeps the referenced value alive until the referrer is collected
|
||||
* @param {Object} referrer
|
||||
* @param {Object} reference
|
||||
*/
|
||||
const untilDeathDoThemPart = (referrer, reference) => {
|
||||
if (!weakReferences.has(referrer)) weakReferences.set(referrer, new Set())
|
||||
weakReferences.get(referrer).add(reference)
|
||||
}
|
||||
|
||||
/** Like AbortController, but resets after each abort */
|
||||
class MultiAbortController {
|
||||
#controller = new AbortController()
|
||||
/** @return {AbortSignal} */
|
||||
get signal() { return this.#controller.signal }
|
||||
abort() { this.#controller.abort(); this.#controller = new AbortController() }
|
||||
}
|
||||
|
||||
/** A symbol representing nothing to be appended to an element */
|
||||
export const nothing = Symbol("Explicit non-argument for Nyooom")
|
||||
|
||||
/** Converts a snake-case string to a CSS property name
|
||||
* @param {string} key
|
||||
* @return {string}
|
||||
*/
|
||||
const snakeToCSS = key => key.replace(/([A-Z])/g, "-$1").replace(/^-/, "--").toLowerCase()
|
||||
const snakeToHTML = key => key.replace(/([A-Z])/g, "-$1").replace(/^-/, "").toLowerCase()
|
||||
|
||||
/** @typedef SpecialAttributeDescriptor
|
||||
* @type {object}
|
||||
* @property {function(Node):void} [get]
|
||||
* @property {function(Node,any):void} [set]
|
||||
* @property {function(Node,function(any):void):void} [subscribe]
|
||||
* @property {function(Node):boolean} [filter]
|
||||
*/
|
||||
|
||||
/** @typedef {EventTarget & {value: any}} Observable */
|
||||
|
||||
/** Cancelable event triggered when a reactive element gets replaced with something else */
|
||||
export class BeforeReplaceEvent extends Event {
|
||||
/** @param {Element|Text} next */
|
||||
constructor(next) {
|
||||
super("nyooom:beforereplace", { cancelable: true })
|
||||
this.next = next
|
||||
}
|
||||
}
|
||||
|
||||
/** Event triggered when a reactive element was replaced */
|
||||
export class AfterReplaceEvent extends Event {
|
||||
/** @param {Element|Text} next */
|
||||
constructor(next) {
|
||||
super("nyooom:afterreplace")
|
||||
this.next = next
|
||||
}
|
||||
}
|
||||
|
||||
/** Event triggered when a new element replaces an old one */
|
||||
export class ReplacedEvent extends Event {
|
||||
/** @param {Element|Text} old */
|
||||
constructor(old) {
|
||||
super("nyooom:replaced", { bubbles: true })
|
||||
this.old = old
|
||||
}
|
||||
}
|
||||
|
||||
/** Event triggered when a reactive attribute changes on an element */
|
||||
export class AttributeEvent extends Event {
|
||||
/**
|
||||
* @param {String} attribute
|
||||
* @param {any} from
|
||||
* @param {any} to
|
||||
*/
|
||||
constructor(attribute, from, to) {
|
||||
super("nyooom:attribute", { cancelable: true })
|
||||
this.attribute = attribute
|
||||
this.from = from
|
||||
this.to = to
|
||||
}
|
||||
}
|
||||
|
||||
// Other utility exports
|
||||
|
||||
/** Wraps an event handler in a function that calls preventDefault on the event
|
||||
* @param {function(event) : event} fn
|
||||
* @return {function(event)}
|
||||
*/
|
||||
export const noDefault = fn => event => { event.preventDefault(); return fn(event) }
|
||||
|
||||
/** Wraps an event handler in a function that calls preventDefault on the event
|
||||
* @param {function(event) : event} fn
|
||||
* @return {function(event)}
|
||||
*/
|
||||
export const noPropagate = fn => event => { event.stopPropagation(); return fn(event) }
|
||||
|
||||
/** Main class doing all the rendering */
|
||||
export class Renderer {
|
||||
static proxy() {
|
||||
return new Proxy(new this(), {
|
||||
/** @param {string} prop */
|
||||
get: (renderer, prop) => /** @param {any[]} args */ (...args) => renderer.node(prop, args),
|
||||
has: (renderer, prop) => renderer.nodeSupported(prop),
|
||||
})
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
node(name, ...args) {
|
||||
throw "Attempting to use an abstract Renderer"
|
||||
}
|
||||
|
||||
/** @param {string|symbol} name */
|
||||
nodeSupported(name) {
|
||||
if (typeof(name) != "string") return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Turns an attribute value into a string */
|
||||
/** @param {any} value */
|
||||
static serialiseAttributeValue(value) {
|
||||
if (typeof value == "string" || typeof value == "number")
|
||||
return value
|
||||
else if (value && "join" in value)
|
||||
return value.join(" ")
|
||||
else if (Object.getPrototypeOf(value) == Object.prototype)
|
||||
return JSON.stringify(value)
|
||||
else
|
||||
return value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
export class DomRenderer extends Renderer {
|
||||
/** @type {Object<string,SpecialAttributeDescriptor>} */
|
||||
static specialAttributes = Object.freeze({})
|
||||
|
||||
/** Processes a list of arguments for an HTML Node
|
||||
* @param {Element|ShadowRoot} element
|
||||
* @param {Array} args
|
||||
*/
|
||||
static apply(element, ...args) {
|
||||
for (const arg of args) if (arg !== nothing) {
|
||||
if (Array.isArray(arg)) {
|
||||
this.apply(element, ...arg)
|
||||
} else {
|
||||
const child = this.toElement(arg)
|
||||
if (child)
|
||||
element.append(child)
|
||||
else if (typeof arg == "function")
|
||||
this.apply(element, arg(element) || nothing)
|
||||
else if (arg instanceof DocumentFragment)
|
||||
element.append(arg)
|
||||
else if (arg && typeof(arg)=="object")
|
||||
for (const key in arg)
|
||||
if (element instanceof Element)
|
||||
this.setAttribute(element, key, arg[key])
|
||||
else
|
||||
throw `Attempting to set attributes on a non-element (${element.constructor.name})`
|
||||
else
|
||||
console.warn(`An argument of type ${typeof arg} has been ignored`, element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a new node
|
||||
* @param {String} name
|
||||
* @param {Array} args
|
||||
*/
|
||||
node(name, args) {
|
||||
const element = this.createElement(name)
|
||||
this.constructor.apply(element, args)
|
||||
return element
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* @param {String} name
|
||||
* @param {Object} options
|
||||
* @return {Node}
|
||||
*/
|
||||
createElement(name, options={}) {
|
||||
return document.createElement(name, options)
|
||||
}
|
||||
|
||||
/** Turns an argument into something that can be inserted as a child into a DOM node
|
||||
* @protected
|
||||
* @param {any} value
|
||||
* @return {Element|Text}
|
||||
*/
|
||||
static toElement(value) {
|
||||
if (typeof value == "string" || typeof value == "number")
|
||||
return document.createTextNode(value.toString())
|
||||
else if (value instanceof Element)
|
||||
return value
|
||||
else if (this.isObservable(value))
|
||||
return this.toReactiveElement(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* @param {Observable} observable
|
||||
* @return {Element|Text}
|
||||
*/
|
||||
static toReactiveElement(observable) {
|
||||
if (observable.value instanceof DocumentFragment) {
|
||||
throw "Failed to create reactive element: Document fragments cannot be replaced dynamically"
|
||||
}
|
||||
const element = this.toElement(observable.value) || document.createComment("Reactive element Placeholder")
|
||||
untilDeathDoThemPart(element, observable)
|
||||
let ref = new WeakRef(element)
|
||||
|
||||
const handleChange = () => {
|
||||
const element = ref.deref()
|
||||
|
||||
if (!element) return
|
||||
|
||||
const next = this.toElement(observable.value)
|
||||
if (element?.dispatchEvent(new BeforeReplaceEvent(next))) {
|
||||
element.replaceWith(next)
|
||||
next.dispatchEvent(new ReplacedEvent(element))
|
||||
untilDeathDoThemPart(next, observable)
|
||||
element.dispatchEvent(new AfterReplaceEvent(next))
|
||||
ref = new WeakRef(next)
|
||||
}
|
||||
observable.addEventListener("changed", handleChange, {once: true})
|
||||
}
|
||||
observable.addEventListener("changed", handleChange, {once: true})
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
/** Set an attribute on an element
|
||||
* @protected
|
||||
* @param {Element} element
|
||||
* @param {string} attribute
|
||||
* @param {any} value
|
||||
* @param {AbortSignal} [cleanupSignal]
|
||||
*/
|
||||
static setAttribute(element, attribute, value, cleanupSignal) {
|
||||
const special = this.getSpecialAttribute(element, attribute)
|
||||
|
||||
if (this.isObservable(value))
|
||||
{ this.setReactiveAttribute(element, attribute, value) }
|
||||
else if (typeof value === "function")
|
||||
element.addEventListener(attribute, value, { signal: cleanupSignal })
|
||||
else if (special?.set)
|
||||
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, this.serialiseAttributeValue(value))
|
||||
}
|
||||
}
|
||||
|
||||
/** Set up a binding between an attribute and an observable
|
||||
* @protected
|
||||
* @param {Element} element
|
||||
* @param {string} attribute
|
||||
* @param {Observable} observable
|
||||
*/
|
||||
static setReactiveAttribute(element, attribute, observable) {
|
||||
const multiAbort = new MultiAbortController()
|
||||
|
||||
observable.addEventListener("changed", () => {
|
||||
multiAbort.abort()
|
||||
if (element.dispatchEvent(new AttributeEvent(attribute, element.getAttribute(attribute), observable.value)))
|
||||
this.setAttribute(element, attribute, observable.value, multiAbort.signal)
|
||||
})
|
||||
this.setAttribute(element, attribute, observable.value, multiAbort.signal)
|
||||
|
||||
const special = this.getSpecialAttribute(element, attribute)
|
||||
|
||||
if (special?.subscribe) {
|
||||
untilDeathDoThemPart(element, observable)
|
||||
special.subscribe(element, value => {
|
||||
if (value != observable.value) observable.value = value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CSSStyleDeclaration} style The style property of a node
|
||||
* @param {object} rules A map of snake case property names to css values
|
||||
*/
|
||||
static insertStyles(style, rules) {
|
||||
for (const [key, value] of Object.entries(rules))
|
||||
if (typeof value == "undefined")
|
||||
style.removeProperty(snakeToCSS(key))
|
||||
else
|
||||
style.setProperty(snakeToCSS(key), value.toString())
|
||||
}
|
||||
|
||||
/** Returns whether an object is an observable according to nyooom's contract
|
||||
* @param {any} object
|
||||
* @return {object is Observable}
|
||||
*/
|
||||
static isObservable(object) {
|
||||
return object && object.observable
|
||||
}
|
||||
|
||||
/** Wraps a list of elements in a document fragment
|
||||
* @param {Array<Element|String>} elements
|
||||
*/
|
||||
static documentFragment(...elements) {
|
||||
const fragment = new DocumentFragment()
|
||||
for (const element of elements)
|
||||
fragment.append(this.toElement(element))
|
||||
return fragment
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* @param {Element} element
|
||||
* @param {String} attribute
|
||||
*/
|
||||
static getSpecialAttribute(element, attribute) {
|
||||
const special = this.specialAttributes[attribute]
|
||||
if (special?.filter == undefined)
|
||||
return special
|
||||
if (special.filter(element))
|
||||
return special
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String|Array<String>} data
|
||||
* @param {Array<String|Element>} items
|
||||
*/
|
||||
static createTextOrFragment(data = "", ...items) {
|
||||
return Array.isArray(data)
|
||||
? this.textFromTemplate(data, items)
|
||||
: document.createTextNode(data)
|
||||
}
|
||||
|
||||
/** Turns a template literal into document fragment.
|
||||
* Strings are returned as text nodes.
|
||||
* Elements are inserted in between.
|
||||
* @param {Array<String>} literals
|
||||
* @param {Array<any>} items
|
||||
* @return {DocumentFragment}
|
||||
*/
|
||||
static textFromTemplate(literals, items) {
|
||||
const fragment = new DocumentFragment()
|
||||
for (const key in items) {
|
||||
fragment.append(document.createTextNode(literals[key]))
|
||||
fragment.append(this.toElement(items[key]))
|
||||
}
|
||||
fragment.append(document.createTextNode(literals[literals.length - 1]))
|
||||
fragment.normalize()
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
||||
/** Renderer for normal HTML nodes targetting a browser's DOM */
|
||||
export class DomHtmlRenderer extends DomRenderer {
|
||||
/**
|
||||
* @param {String} name
|
||||
* @param {Object} options
|
||||
* @return {Node}
|
||||
*/
|
||||
createElement(name, options) {
|
||||
return document.createElement(snakeToHTML(name), options)
|
||||
}
|
||||
|
||||
/** @type {Object<string,SpecialAttributeDescriptor>} */
|
||||
static specialAttributes = {
|
||||
value: {
|
||||
/** @param {HTMLInputElement} element */
|
||||
get(element) { return element.value },
|
||||
/** @param {HTMLInputElement} element */
|
||||
set(element, value) {
|
||||
element.setAttribute("value", value)
|
||||
element.value = value
|
||||
},
|
||||
/** @param {HTMLInputElement} element */
|
||||
subscribe(element, callback) {
|
||||
element.addEventListener("input", () => {
|
||||
callback(this.get(element))
|
||||
})
|
||||
},
|
||||
/** @param {HTMLElement} element */
|
||||
filter(element) {
|
||||
return element.nodeName.toLowerCase() == "input"
|
||||
}
|
||||
},
|
||||
style: {
|
||||
/** @param {HTMLElement} element */
|
||||
set(element, value) { DomRenderer.insertStyles(element.style, value) }
|
||||
},
|
||||
dataset: {
|
||||
/** @param {HTMLElement} element */
|
||||
set(element, value) {
|
||||
for (const [attribute2, value2] of Object.entries(value)) {
|
||||
element.dataset[attribute2] = DomRenderer.serialiseAttributeValue(value2)
|
||||
}
|
||||
}
|
||||
},
|
||||
shadowRoot: {
|
||||
/** @param {HTMLElement} element */
|
||||
set(element, value) {
|
||||
DomRenderer.apply(
|
||||
(element.shadowRoot || element.attachShadow({ mode: "open" })),
|
||||
value
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Renderer for normal SVG nodes targetting a browser's DOM */
|
||||
export class DomSvgRenderer extends DomRenderer {
|
||||
/**
|
||||
* @param {String} name
|
||||
* @param {Object} options
|
||||
* @return {Node}
|
||||
*/
|
||||
createElement(name, options) {
|
||||
return document.createElementNS("http://www.w3.org/2000/svg", name, options)
|
||||
}
|
||||
}
|
||||
|
||||
export const html = DomHtmlRenderer.proxy()
|
||||
export const svg = DomSvgRenderer.proxy()
|
||||
|
||||
export const fragment = DomRenderer.documentFragment.bind(DomRenderer)
|
||||
export const text = DomRenderer.createTextOrFragment.bind(DomRenderer)
|
||||
|
||||
export default html
|
69
styles.css
Normal file
69
styles.css
Normal file
|
@ -0,0 +1,69 @@
|
|||
:root {
|
||||
--corner-radius: 2px;
|
||||
}
|
||||
|
||||
[contenteditable] { white-space: pre }
|
||||
|
||||
#elevator-pitch {
|
||||
& .big {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
& em {
|
||||
font-style: unset;
|
||||
color: var(--secondary-4);
|
||||
@container style(--color-scheme: dark) {
|
||||
color: var(--secondary-7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
&:defined::after {
|
||||
content: "|";
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
line-height: 1.2em;
|
||||
|
||||
p {
|
||||
line-height: 1.6em;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue