From d7b89c27472ab7bfc83ab4c06faf13efe2d1faa4 Mon Sep 17 00:00:00 2001 From: DarkWiiPlayer Date: Mon, 11 Dec 2023 16:53:32 +0100 Subject: [PATCH] [WIP] Start working on website --- index.html | 432 +++++++++++++++++++++++++++++++++++++++++++ js/SkoomaShowcase.js | 124 +++++++++++++ styles.css | 47 +++++ 3 files changed, 603 insertions(+) create mode 100644 index.html create mode 100644 js/SkoomaShowcase.js create mode 100644 styles.css diff --git a/index.html b/index.html new file mode 100644 index 0000000..3953640 --- /dev/null +++ b/index.html @@ -0,0 +1,432 @@ + + + + + + + + + + + + + + + + + +
+ + +
+ + + +
+

Skooma

+ import { html } from "skooma.js" +

+ A new way of building + + HTML, + components and + applications + + in vanilla JavaScript +

+
+ + + Get Started + +
+ + + +
+
+

Elevator Pitch

+ +

+ Skooma lets you elegantly express nested DOM structures in plain JavaScript. +
+ It makes your code more declarative without re-inventing language features like loops and conditions. +

+ + +
return html.p( + "Text Node", + html.br(), + html.b("HTML Node") + )
+
+ +

+ Skooma aims to be what document.createElement should have been. + It creates a node, sets classes and attributes, inserts content and attaches event listeners. +

+ +

Some key characteristics:

+
    +
  • No build steps required +
  • Plays nice with web components +
  • Just vanilla JavaScript +
  • Code mirrors output in structure +
  • Single Responsibility: Generating HTML +
  • No closing tags +
+
+ +
+

Getting Started

+ +
+

+ Trying out skooma is super easy!
+ Just import the html export into your script and start generating DOM nodes. +

+ +
import {html} from "https://cdn.jsdelivr.net/npm/skooma@1.3.1/skooma.min.js"
+
+ +
+

+ To create elements, skooma exposes the html proxy. + Accessing this proxy with any value will return a node factory function. +
+ In its most basic form, these functions will simply generate a new DOM node + of the respective type. +

+ + + +
return html.div()
+
+
+ +

+ If you quickly want to render your HTML to a website to see the result, + the component helper found in sckooma/state.js makes this a lot easier.
+ This wrapper registers a new custom element to insert the results of a function in your document. +

+ +
import {component} from "https://cdn.jsdelivr.net/npm/skooma@1.3.1/state.min.js"
+const myComponent = () => html.div("Rendered Component")
+component(myComponent) //Registers it as <my-component>
+ +

+ This wrapper also provides some state-management features that will be described in a separate section. +

+ +

+ Of course you can also just call document.body.append(html.span("My Text")) in your script. +

+
+
+ +
+

Basic DOM generation

+ +
+

+ 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. +
+ This results in an easy way to compose nested DOM structures. +

+ + + +
return html.span( + "Testing ", + html.u("stuff") +)
+
+
+
+ +
+

+ Attributes are added by passing an object to the function, + where each key-value pair gets turned into an attribute on + the generated node. +

+ + + +
return html.span({ + id: "warning", + class: ["danger", "bold"], + // Arrays get joined with spaces +})
+
+
+
+ +
+

+ 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. +

+ + + +
return html.button( + "Click Me!", + { click: event => { + alert("Button clicked :3") + } } +)
+
+
+
+
+ +
+ +

Leveraging JS features

+ +

+ When generating HTML with skooma, you never stop writing JavaScript. + This means that all JavaScript features are available anywhere + inside your code. Functions like filter or map + 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. +

+ + + +
return html.ul( + ["u", "b", "i"].map( + type => html.li(html[type]( + `<${type}> element` + )) + ) +)
+
+
+ +

+ A more complex example that assembles an array of objects into a table: +

+ + + +
const table = (...rows) => { + const keys = new Set() + for (const row of rows) { + Object.keys(row).forEach( + key => keys.add(key) + ) + } + const header = Array.from(keys) + return html.table( + {class: "full-width"}, + html.thead( + html.tr(header.map( + key => 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} +)
+
+
+
+ +
+

Managing state in Skooma

+
import {State} from 'skooma/state.js'
+ +

+ Reactivity and state management in skooma are split between the core module and the state module. +

+ +

Generator Functions

+

+ The core skooma.js module understands a state object to be anything that is +

+
    +
  1. Not a DOM node +
  2. Has a value property +
  3. Has an addEventListener method +
+

+ When such an object is passed into a generator function as either a child element or an attribute, + skooma will use the value of the state object and register a "change" event + that replaces the attribute or child element with the current value. +

+

+ 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 value. +

+ +

The state module

+

+ This module primarily exposts the State class, + which extends the EventTarget class. +

+

+ Every state object exposes a proxy object via the proxy property. + This proxy can be used like any normal object, but setting any property on it will dispatch a "change" event on the corresponding state object. + The proxy object itself also has a getter and setter for the value property, which gets forwarded directly to proxy.value for easier access. +

+

+ This means that any State object satisfies the API of a state object from the previous section, + meaning they can be used to build reactive components. +

+ + + +
const counter = new State({value: 0}) + +counter.valueChanged = newValue => + console.log(`Value: ${newValue}`) + +return html.flexColumn( + {gap: 1}, + html.button( + "Click Me! ", + html.span(counter), + { click: () => { + counter.value += 1 + }} + ), + html.button( + "Clear", + { click: () => { + counter.value = 0 + }} + ) +)
+
+
+ + +

+ The basic State object is backed by a plain JS object, so their attributes are unique and do not persist page reload.
+ By contrast, the StoredState class, which extends the core State class, is backed by a Storage object like + window.localStorage or window.sessionStorage, meaning that they persist page reloads.
+ 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. +

+
+ +
+ +

A simple Todo list

+ +

+ 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. +

+ + + +
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 => { + 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 => { + todo.append(task( + input.value || + input.placeholder + )) + } + }) +)
+
+
+
+
+ + + diff --git a/js/SkoomaShowcase.js b/js/SkoomaShowcase.js new file mode 100644 index 0000000..51a4337 --- /dev/null +++ b/js/SkoomaShowcase.js @@ -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 + } + } +}) diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..8b37cd1 --- /dev/null +++ b/styles.css @@ -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; + } +}