diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/README.md | 66 | ||||
-rw-r--r-- | lib/gleam.toml | 13 | ||||
-rw-r--r-- | lib/manifest.toml | 11 | ||||
-rw-r--r-- | lib/package-lock.json | 201 | ||||
-rw-r--r-- | lib/package.json | 10 | ||||
-rw-r--r-- | lib/src/lustre.ffi.mjs | 206 | ||||
-rw-r--r-- | lib/src/lustre.gleam | 254 | ||||
-rw-r--r-- | lib/src/lustre/attribute.gleam | 408 | ||||
-rw-r--r-- | lib/src/lustre/effect.gleam | 67 | ||||
-rw-r--r-- | lib/src/lustre/element.gleam | 126 | ||||
-rw-r--r-- | lib/src/lustre/element/html.gleam | 1197 | ||||
-rw-r--r-- | lib/src/lustre/element/svg.gleam | 351 | ||||
-rw-r--r-- | lib/src/lustre/event.gleam | 184 | ||||
-rw-r--r-- | lib/src/runtime.ffi.mjs | 230 | ||||
-rw-r--r-- | lib/test/examples/components.gleam | 102 | ||||
-rw-r--r-- | lib/test/examples/components.html | 17 | ||||
-rw-r--r-- | lib/test/examples/counter.gleam | 56 | ||||
-rw-r--r-- | lib/test/examples/counter.html | 17 | ||||
-rw-r--r-- | lib/test/examples/index.html | 27 | ||||
-rw-r--r-- | lib/test/examples/input.gleam | 132 | ||||
-rw-r--r-- | lib/test/examples/input.html | 54 | ||||
-rw-r--r-- | lib/test/examples/nested.gleam | 57 | ||||
-rw-r--r-- | lib/test/examples/nested.html | 17 | ||||
-rw-r--r-- | lib/test/examples/svg.gleam | 107 | ||||
-rw-r--r-- | lib/test/examples/svg.html | 17 |
25 files changed, 3927 insertions, 0 deletions
diff --git a/lib/README.md b/lib/README.md new file mode 100644 index 0000000..b6d111c --- /dev/null +++ b/lib/README.md @@ -0,0 +1,66 @@ +# Lustre + +An Elm-inspired framework for building web apps in Gleam! + +--- + +[](https://hex.pm/packages/lustre) +[](https://hexdocs.pm/lustre/) + +```gleam +import gleam/int +import lustre +import lustre/element.{button, div, p, text} +import lustre/event.{on_click} +import lustre/cmd + +pub fn main() { + let app = lustre.simple(init, update, render) + let assert Ok(_) = lustre.start(app, "#app") + + Nil +} + +fn init() { + 0 +} + +type Msg { + Incr + Decr +} + +fn update(state, msg) { + case msg { + Incr -> state + 1 + Decr -> state - 1 + } +} + +fn render(state) { + div( + [], + [ + button([on_click(Decr)], [text("-")]), + p([], [text(int.to_string(state))]), + button([on_click(Incr)], [text("+")]), + ], + ) +} +``` + +--- + +❗️ This package relies on Gleam's JavaScript FFI and is intended to be run in +the browser. **It will not work if your are targetting Node.js or Erlang.** + +--- + +## Installation + +Lustre is available on [Hex](https://hex.pm/packages/lustre). You can install +it like any other Hex package: + +```sh +$ gleam add lustre +``` diff --git a/lib/gleam.toml b/lib/gleam.toml new file mode 100644 index 0000000..542b44c --- /dev/null +++ b/lib/gleam.toml @@ -0,0 +1,13 @@ +name = "lustre" +version = "3.0.0-rc.8" + +description = "An Elm-inspired framework for building web apps in Gleam!" +licences = ["MIT"] +links = [{ title = "Buy me a coffee?", href = "https://github.com/sponsors/hayleigh-dot-dev" }] +repository = { type = "github", user = "hayleigh-dot-dev", repo = "gleam-lustre" } +target = "javascript" + +[dependencies] +gleam_stdlib = "~> 0.30" +funtil = "~> 1.0" + diff --git a/lib/manifest.toml b/lib/manifest.toml new file mode 100644 index 0000000..a5dc3b1 --- /dev/null +++ b/lib/manifest.toml @@ -0,0 +1,11 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "funtil", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "funtil", source = "hex", outer_checksum = "408E301240E6039FA0D9AB24E648FC176DFB82486835AFE23FE59B40222CEC9A" }, + { name = "gleam_stdlib", version = "0.30.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "03710B3DA047A3683117591707FCA19D32B980229DD8CE8B0603EB5B5144F6C3" }, +] + +[requirements] +funtil = { version = "~> 1.0" } +gleam_stdlib = { version = "~> 0.30" } diff --git a/lib/package-lock.json b/lib/package-lock.json new file mode 100644 index 0000000..7fafc3d --- /dev/null +++ b/lib/package-lock.json @@ -0,0 +1,201 @@ +{ + "name": "lustre-core", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "vite": "^4.4.2" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.11", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild": { + "version": "0.18.11", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.11", + "@esbuild/android-arm64": "0.18.11", + "@esbuild/android-x64": "0.18.11", + "@esbuild/darwin-arm64": "0.18.11", + "@esbuild/darwin-x64": "0.18.11", + "@esbuild/freebsd-arm64": "0.18.11", + "@esbuild/freebsd-x64": "0.18.11", + "@esbuild/linux-arm": "0.18.11", + "@esbuild/linux-arm64": "0.18.11", + "@esbuild/linux-ia32": "0.18.11", + "@esbuild/linux-loong64": "0.18.11", + "@esbuild/linux-mips64el": "0.18.11", + "@esbuild/linux-ppc64": "0.18.11", + "@esbuild/linux-riscv64": "0.18.11", + "@esbuild/linux-s390x": "0.18.11", + "@esbuild/linux-x64": "0.18.11", + "@esbuild/netbsd-x64": "0.18.11", + "@esbuild/openbsd-x64": "0.18.11", + "@esbuild/sunos-x64": "0.18.11", + "@esbuild/win32-arm64": "0.18.11", + "@esbuild/win32-ia32": "0.18.11", + "@esbuild/win32-x64": "0.18.11" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.6", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.25", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "3.26.2", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "4.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.24", + "rollup": "^3.25.2" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/lib/package.json b/lib/package.json new file mode 100644 index 0000000..fdc091a --- /dev/null +++ b/lib/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "gleam build && vite serve ./test/examples" + }, + "devDependencies": { + "vite": "^4.4.2" + } +} diff --git a/lib/src/lustre.ffi.mjs b/lib/src/lustre.ffi.mjs new file mode 100644 index 0000000..b99f6e2 --- /dev/null +++ b/lib/src/lustre.ffi.mjs @@ -0,0 +1,206 @@ +import { ElementNotFound, ComponentAlreadyRegistered } from "./lustre.mjs"; +import { from } from "./lustre/effect.mjs"; +import { map } from "./lustre/element.mjs"; +import { morph } from "./runtime.ffi.mjs"; +import { Ok, Error, isEqual } from "./gleam.mjs"; + +// RUNTIME --------------------------------------------------------------------- + +/// +/// +export class App { + #root = null; + #state = null; + #queue = []; + #effects = []; + #didUpdate = false; + + #init = null; + #update = null; + #view = null; + + constructor(init, update, render) { + this.#init = init; + this.#update = update; + this.#view = render; + } + + start(selector = "body") { + if (this.#root) return this; + + try { + const el = + selector instanceof HTMLElement + ? selector + : document.querySelector(selector); + const [next, effects] = this.#init(); + + this.#root = el; + this.#state = next; + this.#effects = effects[0].toArray(); + this.#didUpdate = true; + + window.requestAnimationFrame(() => this.#tick()); + + return new Ok((msg) => this.dispatch(msg)); + } catch (_) { + return new Error(new ElementNotFound()); + } + } + + dispatch(msg) { + this.#queue.push(msg); + this.#tick(); + } + + emit(name, event = null) { + this.#root.dispatchEvent( + new CustomEvent(name, { + bubbles: true, + detail: event, + composed: true, + }) + ); + } + + destroy() { + this.#root.remove(); + this.#state = null; + this.#queue = []; + this.#effects = []; + this.#didUpdate = false; + this.#update = () => {}; + this.#view = () => {}; + } + + #render() { + const node = this.#view(this.#state); + const vdom = map(node, (msg) => this.dispatch(msg)); + + morph(this.#root, vdom); + } + + #tick() { + this.#flush(); + this.#didUpdate && this.#render(); + this.#didUpdate = false; + } + + #flush(times = 0) { + if (this.#queue.length) { + while (this.#queue.length) { + const [next, effects] = this.#update(this.#state, this.#queue.shift()); + + this.#state = next; + this.#effects = this.#effects.concat(effects[0].toArray()); + } + this.#didUpdate = true; + } + + // Each update can produce effects which must now be executed. + while (this.#effects[0]) + this.#effects.shift()( + (msg) => this.dispatch(msg), + (name, data) => this.emit(name, data) + ); + + // Synchronous effects will immediately queue a message to be processed. If + // it is reasonable, we can process those updates too before proceeding to + // the next render. + if (this.#queue.length) { + times >= 5 ? console.warn(tooManyUpdates) : this.#flush(++times); + } + } +} + +export const setup = (init, update, render) => new App(init, update, render); +export const start = (app, selector) => app.start(selector); + +export const emit = (name, data) => + // Normal `Effect`s constructed in Gleam from `effect.from` don't get told + // about the second argument, but it's there 👀. + from((_, emit) => { + emit(name, data); + }); + +// HTML EVENTS ----------------------------------------------------------------- + +export const prevent_default = (e) => e.preventDefault?.(); +export const stop_propagation = (e) => e.stopPropagation?.(); + +// CUSTOM ELEMENTS ------------------------------------------------------------- + +export const setup_component = ( + name, + init, + update, + render, + on_attribute_change +) => { + if (customElements.get(name)) { + return new Error(new ComponentAlreadyRegistered()); + } + + customElements.define( + name, + class extends HTMLElement { + static get observedAttributes() { + return on_attribute_change.entries().map(([name, _]) => name); + } + + #container = document.createElement("div"); + #app = null; + #dispatch = null; + + constructor() { + super(); + + this.#app = new App(init, update, render); + const dispatch = this.#app.start(this.#container); + this.#dispatch = dispatch[0]; + + on_attribute_change.forEach((decoder, name) => { + Object.defineProperty(this, name, { + get: () => { + return this[`_${name}`] || this.getAttribute(name); + }, + + set: (value) => { + const prev = this[name]; + const decoded = decoder(value); + + // We need this equality check to prevent constantly dispatching + // messages when the value is an object or array: it might not have + // changed but its reference might have and we don't want to trigger + // useless updates. + if (decoded.isOk() && !isEqual(prev, decoded[0])) { + this.#dispatch(decoded[0]); + } + + if (typeof value === "string") { + this.setAttribute(name, value); + } else { + this[`_${name}`] = value; + } + }, + }); + }); + } + + connectedCallback() { + this.appendChild(this.#container); + } + + attributeChangedCallback(name, prev, next) { + if (prev !== next) { + this[name] = next; + } + } + + disconnectedCallback() { + this.#app.destroy(); + } + } + ); + return new Ok(null); +}; diff --git a/lib/src/lustre.gleam b/lib/src/lustre.gleam new file mode 100644 index 0000000..673f982 --- /dev/null +++ b/lib/src/lustre.gleam @@ -0,0 +1,254 @@ +//// Lustre is a declarative framework for building Web apps in Gleam. + +// IMPORTS --------------------------------------------------------------------- + +import gleam/dynamic.{Decoder} +import gleam/map.{Map} +import lustre/effect.{Effect} +import lustre/element.{Element} + +// TYPES ----------------------------------------------------------------------- + +/// An `App` describes a Lustre application: what state it holds and what kind +/// of actions get dispatched to update that model. The only useful thing you can +/// do with an `App` is pass it to [`start`](#start). +/// +/// You can construct an `App` from the two constructors exposed in this module: +/// [`basic`](#basic) and [`application`](#application). Although you can't do +/// anything but [`start`](#start) them, the constructors are separated in case +/// you want to set up an application but defer starting it until some later point +/// in time. +/// +/// ```text +/// +--------+ +/// | | +/// | update | +/// | | +/// +--------+ +/// ^ | +/// | | +/// Msg | | #(Model, Effect(Msg)) +/// | | +/// | v +/// +------+ +------------------------+ +/// | | #(Model, Effect(Msg)) | | +/// | init |------------------------>| Lustre Runtime | +/// | | | | +/// +------+ +------------------------+ +/// ^ | +/// | | +/// Msg | | Model +/// | | +/// | v +/// +--------+ +/// | | +/// | render | +/// | | +/// +--------+ +/// ``` +/// +pub type App(model, msg) + +pub type Error { + ElementNotFound + ComponentAlreadyRegistered +} + +// These types aren't exposed, but they're just here to try and shrink the type +// annotations for `App` and `application` a little bit. When generating docs, +// Gleam automatically expands type aliases so this is purely for the benefit of +// those reading the source. +// + +type Update(model, msg) = + fn(model, msg) -> #(model, Effect(msg)) + +type Render(model, msg) = + fn(model) -> Element(msg) + +// CONSTRUCTORS ---------------------------------------------------------------- + +@target(javascript) +/// Create a basic lustre app that just renders some element on the page. +/// Note that this doesn't mean the content is static! With `element.stateful` +/// you can still create components with local state. +/// +/// Basic lustre apps don't have any *global* application state and so the +/// plumbing is a lot simpler. If you find yourself passing lots of state around, +/// you might want to consider using [`simple`](#simple) or [`application`](#application) +/// instead. +/// +/// ```gleam +/// import lustre +/// import lustre/element +/// +/// pub fn main () { +/// let app = lustre.element( +/// element.h1([], [ +/// element.text("Hello, world!") +/// ]) +/// ) +/// +/// assert Ok(_) = lustre.start(app, "#root") +/// } +/// ``` +/// +pub fn element(element: Element(msg)) -> App(Nil, msg) { + let init = fn() { #(Nil, effect.none()) } + let update = fn(_, _) { #(Nil, effect.none()) } + let render = fn(_) { element } + + application(init, update, render) +} + +@target(javascript) +/// If you start off with a simple `[element`](#element) app, you may find +/// yourself leaning on [`stateful`](./lustrel/element.html#stateful) elements +/// to manage model used throughout your app. If that's the case or if you know +/// you need some global model from the get-go, you might want to construct a +/// [`simple`](#simple) app instead. +/// +/// This is one app constructor that allows your HTML elements to dispatch actions +/// to update your program model. +/// +/// ```gleam +/// import gleam/int +/// import lustre +/// import lustre/element +/// import lustre/event +/// +/// type Msg { +/// Decr +/// Incr +/// } +/// +/// pub fn main () { +/// let init = 0 +/// +/// let update = fn (model, msg) { +/// case msg { +/// Decr -> model - 1 +/// Incr -> model + 1 +/// } +/// } +/// +/// let render = fn (model) { +/// element.div([], [ +/// element.button([ event.on_click(Decr) ], [ +/// element.text("-") +/// ]), +/// +/// element.text(int.to_string(model)), +/// +/// element.button([ event.on_click(Incr) ], [ +/// element.text("+") +/// ]) +/// ]) +/// } +/// +/// let app = lustre.simple(init, update, render) +/// assert Ok(_) = lustre.start(app, "#root") +/// } +/// ``` +/// +pub fn simple( + init: fn() -> model, + update: fn(model, msg) -> model, + render: fn(model) -> Element(msg), +) -> App(model, msg) { + let init = fn() { #(init(), effect.none()) } + let update = fn(model, msg) { #(update(model, msg), effect.none()) } + + application(init, update, render) +} + +@target(javascript) +/// An evolution of a [`simple`](#simple) app that allows you to return a +/// [`Effect`](./lustre/effect.html#Effect) from your `init` and `update`s. Commands give +/// us a way to perform side effects like sending an HTTP request or running a +/// timer and then dispatch actions back to the runtime to trigger an `update`. +/// +///``` +/// import lustre +/// import lustre/effect +/// import lustre/element +/// +/// pub fn main () { +/// let init = #(0, tick()) +/// +/// let update = fn (model, msg) { +/// case msg { +/// Tick -> #(model + 1, tick()) +/// } +/// } +/// +/// let render = fn (model) { +/// element.div([], [ +/// element.text("Time elapsed: ") +/// element.text(int.to_string(model)) +/// ]) +/// } +/// +/// let app = lustre.simple(init, update, render) +/// assert Ok(_) = lustre.start(app, "#root") +/// } +/// +/// fn tick () -> Effect(Msg) { +/// effect.from(fn (dispatch) { +/// setInterval(fn () { +/// dispatch(Tick) +/// }, 1000) +/// }) +/// } +/// +/// external fn set_timeout (f: fn () -> a, delay: Int) -> Nil +/// = "" "window.setTimeout" +///``` +@external(javascript, "./lustre.ffi.mjs", "setup") +pub fn application(init: fn() -> #(model, Effect(msg)), update: Update( + model, + msg, + ), render: Render(model, msg)) -> App(model, msg) + +@target(javascript) +@external(javascript, "./lustre.ffi.mjs", "setup_component") +pub fn component(name: String, init: fn() -> #(model, Effect(msg)), update: Update( + model, + msg, + ), render: Render(model, msg), on_attribute_change: Map(String, Decoder(msg))) -> Result( + Nil, + Error, +) + +// EFFECTS --------------------------------------------------------------------- + +@target(javascript) +/// Once you have created a app with either `basic` or `application`, you +/// need to actually start it! This function will mount your app to the DOM +/// node that matches the query selector you provide. +/// +/// If everything mounted OK, we'll get back a dispatch function that you can +/// call to send actions to your app and trigger an update. +/// +///``` +/// import lustre +/// +/// pub fn main () { +/// let app = lustre.appliation(init, update, render) +/// assert Ok(dispatch) = lustre.start(app, "#root") +/// +/// dispatch(Incr) +/// dispatch(Incr) +/// dispatch(Incr) +/// } +///``` +/// +/// This may not seem super useful at first, but by returning this dispatch +/// function from your `main` (or elsewhere) you can get events into your Lustre +/// app from the outside world. +/// +@external(javascript, "./lustre.ffi.mjs", "start") +pub fn start(app: App(model, msg), selector: String) -> Result( + fn(msg) -> Nil, + Error, +) diff --git a/lib/src/lustre/attribute.gleam b/lib/src/lustre/attribute.gleam new file mode 100644 index 0000000..459a86e --- /dev/null +++ b/lib/src/lustre/attribute.gleam @@ -0,0 +1,408 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/dynamic.{Dynamic} +import gleam/int +import gleam/list +import gleam/option.{Option} +import gleam/string +import gleam/string_builder.{StringBuilder} + +// TYPES ----------------------------------------------------------------------- + +/// Attributes are attached to specific elements. They're either key/value pairs +/// or event handlers. +/// +pub opaque type Attribute(msg) { + Attribute(String, Dynamic) + Event(String, fn(Dynamic) -> Option(msg)) +} + +// CONSTRUCTORS ---------------------------------------------------------------- + +/// +/// Lustre does some work internally to convert common Gleam values into ones that +/// make sense for JavaScript. Here are the types that are converted: +/// +/// - `List(a)` -> `Array(a)` +/// - `Some(a)` -> `a` +/// - `None` -> `undefined` +/// +pub fn attribute(name: String, value: String) -> Attribute(msg) { + escape("", value) + |> dynamic.from + |> Attribute(name, _) +} + +/// +pub fn property(name: String, value: any) -> Attribute(msg) { + Attribute(name, dynamic.from(value)) +} + +fn escape(escaped: String, content: String) -> String { + case string.pop_grapheme(content) { + Ok(#("<", xs)) -> escape(escaped <> "<", xs) + Ok(#(">", xs)) -> escape(escaped <> ">", xs) + Ok(#("&", xs)) -> escape(escaped <> "&", xs) + Ok(#("\"", xs)) -> escape(escaped <> """, xs) + Ok(#("'", xs)) -> escape(escaped <> "'", xs) + Ok(#(x, xs)) -> escape(escaped <> x, xs) + Error(_) -> escaped <> content + } +} + +/// Attach custom event handlers to an element. A number of helper functions exist +/// in this module to cover the most common events and use-cases, so you should +/// check those out first. +/// +/// If you need to handle an event that isn't covered by the helper functions, +/// then you can use `on` to attach a custom event handler. The callback is given +/// the event object as a `Dynamic`. +/// +/// As a simple example, you can implement `on_click` like so: +/// +/// ```gleam +/// import gleam/option.{Some} +/// import lustre/attribute.{Attribute} +/// import lustre/event +/// +/// pub fn on_click(msg: msg) -> Attribute(msg) { +/// use _ <- event.on("click") +/// Some(msg) +/// } +/// ``` +/// +/// By using `gleam/dynamic` you can decode the event object and pull out all sorts +/// of useful data. This is how `on_input` is implemented: +/// +/// ```gleam +/// import gleam/dynamic +/// import gleam/option.{None, Some} +/// import gleam/result +/// import lustre/attribute.{Attribute} +/// import lustre/event +/// +/// pub fn on_input(msg: fn(String) -> msg) -> Attribute(msg) { +/// use event, dispatch <- on("input") +/// let decode = dynamic.field("target", dynamic.field("value", dynamic.string)) +/// +/// case decode(event) { +/// Ok(value) -> Some(msg(value)) +/// Error(_) -> None +/// } +/// } +/// ``` +/// +/// You can take a look at the MDN reference for events +/// [here](https://developer.mozilla.org/en-US/docs/Web/API/Event) to see what +/// you can decode. +/// +/// Unlike the helpers in the rest of this module, it is possible to simply ignore +/// the dispatch function and not dispatch a message at all. In fact, we saw this +/// with the `on_input` example above: if we can't decode the event object, we +/// simply return `None` and emit nothing. +/// +/// Beyond ignoring errors, this can be used to perform side effects we don't need +/// to observe in our main application loop, such as logging... +/// +/// ```gleam +/// import gleam/io +/// import gleam/option.{None} +/// import lustre/attribute.{Attribute} +/// import lustre/event +/// +/// pub fn log_on_click(msg: String) -> Attribute(msg) { +/// use _ <- event.on("click") +/// io.println(msg) +/// None +/// } +/// ``` +/// +pub fn on(name: String, handler: fn(Dynamic) -> Option(msg)) -> Attribute(msg) { + Event("on" <> name, handler) +} + +// MANIPULATIONS --------------------------------------------------------------- + +/// +/// +pub fn map(attr: Attribute(a), f: fn(a) -> b) -> Attribute(b) { + case attr { + Attribute(name, value) -> Attribute(name, value) + Event(on, handler) -> Event(on, fn(e) { option.map(handler(e), f) }) + } +} + +// CONVERSIONS ----------------------------------------------------------------- + +/// +/// +pub fn to_string(attr: Attribute(msg)) -> String { + case attr { + Attribute(name, value) -> { + case dynamic.classify(value) { + "String" -> name <> "=\"" <> dynamic.unsafe_coerce(value) <> "\"" + + // Boolean attributes are determined based on their presence, eg we don't + // want to render `disabled="false"` if the value is `false` we simply + // want to omit the attribute altogether. + "Boolean" -> + case dynamic.unsafe_coerce(value) { + True -> name + False -> "" + } + + // For everything else we'll just make a best-effort serialisation. + _ -> name <> "=\"" <> string.inspect(value) <> "\"" + } + } + Event(on, _) -> "data-lustre-on:" <> on + } +} + +/// +/// +pub fn to_string_builder(attr: Attribute(msg)) -> StringBuilder { + case attr { + Attribute(name, value) -> { + case dynamic.classify(value) { + "String" -> + [name, "=\"", dynamic.unsafe_coerce(value), "\""] + |> string_builder.from_strings + + // Boolean attributes are determined based on their presence, eg we don't + // want to render `disabled="false"` if the value is `false` we simply + // want to omit the attribute altogether. + "Boolean" -> + case dynamic.unsafe_coerce(value) { + True -> string_builder.from_string(name) + False -> string_builder.new() + } + + // For everything else we'll just make a best-effort serialisation. + _ -> + [name, "=\"", string.inspect(value), "\""] + |> string_builder.from_strings + } + } + Event(on, _) -> + ["data-lustre-on:", on] + |> string_builder.from_strings + } +} + +// COMMON ATTRIBUTES ----------------------------------------------------------- + +/// +pub fn style(properties: List(#(String, String))) -> Attribute(msg) { + attribute( + "style", + { + use styles, #(name, value) <- list.fold(properties, "") + styles <> name <> ":" <> value <> ";" + }, + ) +} + +/// +pub fn class(name: String) -> Attribute(msg) { + attribute("class", name) +} + +/// +pub fn classes(names: List(#(String, Bool))) -> Attribute(msg) { + attribute( + "class", + names + |> list.filter_map(fn(class) { + case class.1 { + True -> Ok(class.0) + False -> Error(Nil) + } + }) + |> string.join(" "), + ) +} + +/// +pub fn id(name: String) -> Attribute(msg) { + attribute("id", name) +} + +// INPUTS ---------------------------------------------------------------------- + +/// +pub fn type_(name: String) -> Attribute(msg) { + attribute("type", name) +} + +/// +pub fn value(val: Dynamic) -> Attribute(msg) { + property("value", val) +} + +/// +pub fn checked(is_checked: Bool) -> Attribute(msg) { + property("checked", is_checked) +} + +/// +pub fn placeholder(text: String) -> Attribute(msg) { + attribute("placeholder", text) +} + +/// +pub fn selected(is_selected: Bool) -> Attribute(msg) { + property("selected", is_selected) +} + +// INPUT HELPERS --------------------------------------------------------------- + +/// +pub fn accept(types: List(String)) -> Attribute(msg) { + attribute("accept", string.join(types, " ")) +} + +/// +pub fn accept_charset(types: List(String)) -> Attribute(msg) { + attribute("acceptCharset", string.join(types, " ")) +} + +/// +pub fn msg(uri: String) -> Attribute(msg) { + attribute("msg", uri) +} + +/// +pub fn autocomplete(name: String) -> Attribute(msg) { + attribute("autocomplete", name) +} + +/// +pub fn autofocus(should_autofocus: Bool) -> Attribute(msg) { + property("autoFocus", should_autofocus) +} + +/// +pub fn disabled(is_disabled: Bool) -> Attribute(msg) { + property("disabled", is_disabled) +} + +/// +pub fn name(name: String) -> Attribute(msg) { + attribute("name", name) +} + +/// +pub fn pattern(regex: String) -> Attribute(msg) { + attribute("pattern", regex) +} + +/// +pub fn readonly(is_readonly: Bool) -> Attribute(msg) { + property("readonly", is_readonly) +} + +/// +pub fn required(is_required: Bool) -> Attribute(msg) { + property("required", is_required) +} + +/// +pub fn for(id: String) -> Attribute(msg) { + attribute("for", id) +} + +// INPUT RANGES ---------------------------------------------------------------- + +/// +pub fn max(val: String) -> Attribute(msg) { + attribute("max", val) +} + +/// +pub fn min(val: String) -> Attribute(msg) { + attribute("min", val) +} + +/// +pub fn step(val: String) -> Attribute(msg) { + attribute("step", val) +} + +// INPUT TEXT AREAS ------------------------------------------------------------ + +/// +pub fn cols(val: Int) -> Attribute(msg) { + attribute("cols", int.to_string(val)) +} + +/// +pub fn rows(val: Int) -> Attribute(msg) { + attribute("rows", int.to_string(val)) +} + +/// +pub fn wrap(mode: String) -> Attribute(msg) { + attribute("wrap", mode) +} + +// LINKS AND AREAS ------------------------------------------------------------- + +/// +pub fn href(uri: String) -> Attribute(msg) { + attribute("href", uri) +} + +/// +pub fn target(target: String) -> Attribute(msg) { + attribute("target", target) +} + +/// +pub fn download(filename: String) -> Attribute(msg) { + attribute("download", filename) +} + +/// +pub fn rel(relationship: String) -> Attribute(msg) { + attribute("rel", relationship) +} + +// EMBEDDED CONTENT ------------------------------------------------------------ + +/// +pub fn src(uri: String) -> Attribute(msg) { + attribute("src", uri) +} + +/// +pub fn height(val: Int) -> Attribute(msg) { + property("height", int.to_string(val)) +} + +/// +pub fn width(val: Int) -> Attribute(msg) { + property("width", int.to_string(val)) +} + +/// +pub fn alt(text: String) -> Attribute(msg) { + attribute("alt", text) +} + +// AUDIO AND VIDEO ------------------------------------------------------------- + +/// +pub fn autoplay(should_autoplay: Bool) -> Attribute(msg) { + property("autoplay", should_autoplay) +} + +/// +pub fn controls(visible: Bool) -> Attribute(msg) { + property("controls", visible) +} + +/// +pub fn loop(should_loop: Bool) -> Attribute(msg) { + property("loop", should_loop) +} diff --git a/lib/src/lustre/effect.gleam b/lib/src/lustre/effect.gleam new file mode 100644 index 0000000..19f54b0 --- /dev/null +++ b/lib/src/lustre/effect.gleam @@ -0,0 +1,67 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/list + +// TYPES ----------------------------------------------------------------------- + +/// A `Effect` represents some side effect we want the Lustre runtime to perform. +/// It is parameterised by our app's `action` type because some effects need to +/// get information back into your program. +/// +pub opaque type Effect(action) { + Effect(List(fn(fn(action) -> Nil) -> Nil)) +} + +// CONSTRUCTORS ---------------------------------------------------------------- + +/// Create a `Effect` from some custom side effect. This is mostly useful for +/// package authors, or for integrating other libraries into your Lustre app. +/// +/// We pass in a function that recieves a `dispatch` callback that can be used +/// to send messages to the Lustre runtime. We could, for example, create a `tick` +/// command that uses the `setTimeout` JavaScript API to send a message to the +/// runtime every second: +/// +/// ```gleam +/// import lustre/effect.{Effect} +/// +/// external fn set_interval(callback: fn() -> any, interval: Int) = +/// "" "window.setInterval" +/// +/// pub fn every_second(msg: msg) -> Effect(msg) { +/// use dispatch <- effect.from +/// +/// set_interval(fn() { dispatch(msg) }, 1000) +/// } +/// ``` +/// +pub fn from(effect: fn(fn(action) -> Nil) -> Nil) -> Effect(action) { + Effect([effect]) +} + +/// Typically our app's `update` function needs to return a tuple of +/// `#(model, Effect(action))`. When we don't need to perform any side effects we +/// can just return `none()`! +/// +pub fn none() -> Effect(action) { + Effect([]) +} + +// MANIPULATIONS --------------------------------------------------------------- + +/// +/// +pub fn batch(cmds: List(Effect(action))) -> Effect(action) { + Effect({ + use b, Effect(a) <- list.fold(cmds, []) + list.append(b, a) + }) +} + +pub fn map(effect: Effect(a), f: fn(a) -> b) -> Effect(b) { + let Effect(l) = effect + Effect(list.map( + l, + fn(effect) { fn(dispatch) { effect(fn(a) { dispatch(f(a)) }) } }, + )) +} diff --git a/lib/src/lustre/element.gleam b/lib/src/lustre/element.gleam new file mode 100644 index 0000000..4e8abee --- /dev/null +++ b/lib/src/lustre/element.gleam @@ -0,0 +1,126 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/list +import gleam/string +import gleam/string_builder.{StringBuilder} +import lustre/attribute.{Attribute} + +// TYPES ----------------------------------------------------------------------- + +/// +/// +pub opaque type Element(msg) { + Text(String) + Element(String, List(Attribute(msg)), List(Element(msg))) + ElementNs(String, List(Attribute(msg)), List(Element(msg)), String) +} + +// CONSTRUCTORS ---------------------------------------------------------------- + +/// +/// +pub fn element( + tag: String, + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + Element(tag, attrs, children) +} + +/// +/// +pub fn namespaced( + namespace: String, + tag: String, + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + ElementNs(tag, attrs, children, namespace) +} + +/// +/// +pub fn text(content: String) -> Element(msg) { + Text(content) +} + +fn escape(escaped: String, content: String) -> String { + case string.pop_grapheme(content) { + Ok(#("<", xs)) -> escape(escaped <> "<", xs) + Ok(#(">", xs)) -> escape(escaped <> ">", xs) + Ok(#("&", xs)) -> escape(escaped <> "&", xs) + Ok(#("\"", xs)) -> escape(escaped <> """, xs) + Ok(#("'", xs)) -> escape(escaped <> "'", xs) + Ok(#(x, xs)) -> escape(escaped <> x, xs) + Error(_) -> escaped <> content + } +} + +// MANIPULATIONS --------------------------------------------------------------- + +/// +/// +pub fn map(element: Element(a), f: fn(a) -> b) -> Element(b) { + case element { + Text(content) -> Text(content) + Element(tag, attrs, children) -> + Element( + tag, + list.map(attrs, attribute.map(_, f)), + list.map(children, map(_, f)), + ) + ElementNs(tag, attrs, children, namespace) -> + ElementNs( + tag, + list.map(attrs, attribute.map(_, f)), + list.map(children, map(_, f)), + namespace, + ) + } +} + +// CONVERSIONS ----------------------------------------------------------------- + +/// +/// +pub fn to_string(element: Element(msg)) -> String { + to_string_builder(element) + |> string_builder.to_string +} + +/// +/// +pub fn to_string_builder(element: Element(msg)) -> StringBuilder { + case element { + Text(content) -> string_builder.from_string(escape("", content)) + Element(tag, attrs, children) -> + string_builder.from_string("<" <> tag) + |> attrs_to_string_builder(attrs) + |> string_builder.append(">") + |> children_to_string_builder(children) + |> string_builder.append("</" <> tag <> ">") + ElementNs(tag, attrs, children, namespace) -> + string_builder.from_string("<" <> tag) + |> attrs_to_string_builder(attrs) + |> string_builder.append(" xmlns=\"" <> namespace <> "\"") + |> string_builder.append(">") + |> children_to_string_builder(children) + |> string_builder.append("</" <> tag <> ">") + } +} + +fn attrs_to_string_builder( + html: StringBuilder, + attrs: List(Attribute(msg)), +) -> StringBuilder { + use html, attr <- list.fold(attrs, html) + string_builder.append_builder(html, attribute.to_string_builder(attr)) +} + +fn children_to_string_builder( + html: StringBuilder, + children: List(Element(msg)), +) -> StringBuilder { + use html, child <- list.fold(children, html) + string_builder.append_builder(html, to_string_builder(child)) +} diff --git a/lib/src/lustre/element/html.gleam b/lib/src/lustre/element/html.gleam new file mode 100644 index 0000000..9eb4f5e --- /dev/null +++ b/lib/src/lustre/element/html.gleam @@ -0,0 +1,1197 @@ +// IMPORTS --------------------------------------------------------------------- + +import lustre/element.{Element, element, namespaced, text} +import lustre/attribute.{Attribute} + +// The doc comments (and order) for functions in this module are taken from the +// MDN Element reference: +// +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element +// + +// HTML ELEMENTS: MAIN ROOT ---------------------------------------------------- + +/// Represents the root (top-level element) of an HTML document, so it is also +/// referred to as the root element. All other elements must be descendants of +/// this element. +/// +pub fn html( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("html", attrs, children) +} + +// HTML ELEMENTS: DOCUMENT METADATA -------------------------------------------- + +/// Specifies the base URL to use for all relative URLs in a document. There can +/// be only one such element in a document. +/// +pub fn base(attrs: List(Attribute(msg))) -> Element(msg) { + element("base", attrs, []) +} + +/// Contains machine-readable information (metadata) about the document, like its +/// title, scripts, and style sheets. +/// +pub fn head(attrs: List(Attribute(msg))) -> Element(msg) { + element("head", attrs, []) +} + +/// Specifies relationships between the current document and an external resource. +/// This element is most commonly used to link to CSS but is also used to establish +/// site icons (both "favicon" style icons and icons for the home screen and apps +/// on mobile devices) among other things. +/// +pub fn link(attrs: List(Attribute(msg))) -> Element(msg) { + element("link", attrs, []) +} + +/// Represents metadata that cannot be represented by other HTML meta-related +/// elements, like <base>, <link>, <script>, <style> and <title>. +/// +pub fn meta(attrs: List(Attribute(msg))) -> Element(msg) { + element("meta", attrs, []) +} + +/// Contains style information for a document or part of a document. It contains +/// CSS, which is applied to the contents of the document containing this element. +/// +pub fn style(attrs: List(Attribute(msg)), css: String) -> Element(msg) { + element("style", attrs, [text(css)]) +} + +/// Defines the document's title that is shown in a browser's title bar or a +/// page's tab. It only contains text; tags within the element are ignored. +/// +pub fn title(attrs: List(Attribute(msg)), content: String) -> Element(msg) { + element("title", attrs, [text(content)]) +} + +// HTML ELEMENTS: SECTIONING ROOT ----------------------------------------------- + +/// Represents the content of an HTML document. There can be only one such element +/// in a document. +/// +pub fn body( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("body", attrs, children) +} + +// HTML ELEMENTS: CONTENT SECTIONING ------------------------------------------- + +/// Indicates that the enclosed HTML provides contact information for a person or +/// people, or for an organization. +/// +pub fn address( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("address", attrs, children) +} + +/// Represents a self-contained composition in a document, page, application, or +/// site, which is intended to be independently distributable or reusable (e.g., +/// in syndication). Examples include a forum post, a magazine or newspaper article, +/// a blog entry, a product card, a user-submitted comment, an interactive widget +/// or gadget, or any other independent item of content. +/// +pub fn article( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("article", attrs, children) +} + +/// Represents a portion of a document whose content is only indirectly related +/// to the document's main content. Asides are frequently presented as sidebars +/// or call-out boxes. +/// +pub fn aside( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("aside", attrs, children) +} + +/// Represents a footer for its nearest ancestor sectioning content or sectioning +/// root element. A <footer> typically contains information about the author of +/// the section, copyright data, or links to related documents. +/// +pub fn footer( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("footer", attrs, children) +} + +/// Represents introductory content, typically a group of introductory or navigational +/// aids. It may contain some heading elements but also a logo, a search form, an +/// author name, and other elements. +/// +pub fn header( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("header", attrs, children) +} + +/// Represent six levels of section headings. <h1> is the highest section level +/// and <h6> is the lowest. +/// +pub fn h1( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("h1", attrs, children) +} + +/// Represent six levels of section headings. <h1> is the highest section level +/// and <h6> is the lowest. +/// +pub fn h2( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("h2", attrs, children) +} + +/// Represent six levels of section headings. <h1> is the highest section level +/// and <h6> is the lowest. +/// +pub fn h3( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("h3", attrs, children) +} + +/// Represent six levels of section headings. <h1> is the highest section level +/// and <h6> is the lowest. +/// +pub fn h4( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("h4", attrs, children) +} + +/// Represent six levels of section headings. <h1> is the highest section level +/// and <h6> is the lowest. +/// +pub fn h5( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("h5", attrs, children) +} + +/// Represent six levels of section headings. <h1> is the highest section level +/// and <h6> is the lowest. +/// +pub fn h6( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("h6", attrs, children) +} + +/// Represents a heading grouped with any secondary content, such as subheadings, +/// an alternative title, or a tagline. +/// +pub fn hgroup( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("hgroup", attrs, children) +} + +/// Represents the dominant content of the body of a document. The main content +/// area consists of content that is directly related to or expands upon the +/// central topic of a document, or the central functionality of an application. +/// +pub fn main( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("main", attrs, children) +} + +/// Represents a section of a page whose purpose is to provide navigation links, +/// either within the current document or to other documents. Common examples of +/// navigation sections are menus, tables of contents, and indexes. +/// +pub fn nav( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("nav", attrs, children) +} + +/// Represents a generic standalone section of a document, which doesn't have a +/// more specific semantic element to represent it. Sections should always have +/// a heading, with very few exceptions. +/// +pub fn section( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("section", attrs, children) +} + +/// Represents a part that contains a set of form controls or other content related +/// to performing a search or filtering operation. +/// +pub fn search( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("search", attrs, children) +} + +// HTML ELEMENTS: TEXT CONTENT ------------------------------------------------- + +/// Indicates that the enclosed text is an extended quotation. Usually, this is +/// rendered visually by indentation. A URL for the source of the quotation may +/// be given using the cite attribute, while a text representation of the source +/// can be given using the <cite> element. +/// +pub fn blockquote( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("blockquote", attrs, children) +} + +/// Provides the description, definition, or value for the preceding term (<dt>) +/// in a description list (<dl>). +/// +pub fn dd( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("dd", attrs, children) +} + +/// The generic container for flow content. It has no effect on the content or +/// layout until styled in some way using CSS (e.g., styling is directly applied +/// to it, or some kind of layout model like flexbox is applied to its parent +/// element). +/// +pub fn div( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("div", attrs, children) +} + +/// Represents a description list. The element encloses a list of groups of terms +/// (specified using the <dt> element) and descriptions (provided by <dd> elements). +/// Common uses for this element are to implement a glossary or to display metadata +/// (a list of key-value pairs). +/// +pub fn dl( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("dl", attrs, children) +} + +/// Specifies a term in a description or definition list, and as such must be +/// used inside a <dl> element. It is usually followed by a <dd> element; +/// however, multiple <dt> elements in a row indicate several terms that are +/// all defined by the immediate next <dd> element. +/// +pub fn dt( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("dt", attrs, children) +} + +/// Represents a caption or legend describing the rest of the contents of its +/// parent <figure> element. +/// +pub fn figcaption( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("figcaption", attrs, children) +} + +/// Represents self-contained content, potentially with an optional caption, +/// which is specified using the <figcaption> element. The figure, its caption, +/// and its contents are referenced as a single unit. +/// +pub fn figure( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("figure", attrs, children) +} + +/// Represents a thematic break between paragraph-level elements: for example, +/// a change of scene in a story, or a shift of topic within a section. +/// +pub fn hr(attrs: List(Attribute(msg))) -> Element(msg) { + element("hr", attrs, []) +} + +/// Represents an item in a list. It must be contained in a parent element: an +/// ordered list (<ol>), an unordered list (<ul>), or a menu (<menu>). In menus +/// and unordered lists, list items are usually displayed using bullet points. +/// In ordered lists, they are usually displayed with an ascending counter on +/// the left, such as a number or letter. +/// +pub fn li( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("li", attrs, children) +} + +/// A semantic alternative to <ul>, but treated by browsers (and exposed through +/// the accessibility tree) as no different than <ul>. It represents an unordered +/// list of items (which are represented by <li> elements). +/// +pub fn menu( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("menu", attrs, children) +} + +/// Represents an ordered list of items — typically rendered as a numbered list. +/// +pub fn ol( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("ol", attrs, children) +} + +/// Represents a paragraph. Paragraphs are usually represented in visual media +/// as blocks of text separated from adjacent blocks by blank lines and/or +/// first-line indentation, but HTML paragraphs can be any structural grouping +/// of related content, such as images or form fields. +/// +pub fn p( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("p", attrs, children) +} + +/// Represents preformatted text which is to be presented exactly as written in +/// the HTML file. The text is typically rendered using a non-proportional, or +/// monospaced, font. Whitespace inside this element is displayed as written. +/// +pub fn pre( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("pre", attrs, children) +} + +/// Represents an unordered list of items, typically rendered as a bulleted list. +/// +pub fn ul( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("ul", attrs, children) +} + +// HTML ELEMENTS: INLINE TEXT SEMANTICS ---------------------------------------- + +/// Together with its href attribute, creates a hyperlink to web pages, files, +/// email addresses, locations within the current page, or anything else a URL +/// can address. +/// +pub fn a( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("a", attrs, children) +} + +/// Represents an abbreviation or acronym. +/// +pub fn abbr( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("abbr", attrs, children) +} + +/// Used to draw the reader's attention to the element's contents, which are not +/// otherwise granted special importance. This was formerly known as the Boldface +/// element, and most browsers still draw the text in boldface. However, you +/// should not use <b> for styling text or granting importance. If you wish to +/// create boldface text, you should use the CSS font-weight property. If you +/// wish to indicate an element is of special importance, you should use the +/// strong element. +/// +pub fn b( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("b", attrs, children) +} + +/// Tells the browser's bidirectional algorithm to treat the text it contains in +/// isolation from its surrounding text. It's particularly useful when a website +/// dynamically inserts some text and doesn't know the directionality of the +/// text being inserted. +/// +pub fn bdi( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("bdi", attrs, children) +} + +/// Overrides the current directionality of text, so that the text within is +/// rendered in a different direction. +/// +pub fn bdo( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("bdo", attrs, children) +} + +/// Produces a line break in text (carriage-return). It is useful for writing a +/// poem or an address, where the division of lines is significant. +/// +pub fn br(attrs: List(Attribute(msg))) -> Element(msg) { + element("br", attrs, []) +} + +/// Used to mark up the title of a cited creative work. The reference may be in +/// an abbreviated form according to context-appropriate conventions related to +/// citation metadata. +/// +pub fn cite( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("cite", attrs, children) +} + +/// Displays its contents styled in a fashion intended to indicate that the text +/// is a short fragment of computer code. By default, the content text is +/// displayed using the user agent's default monospace font. +/// +pub fn code( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("code", attrs, children) +} + +/// Links a given piece of content with a machine-readable translation. If the +/// content is time- or date-related, the<time> element must be used. +/// +pub fn data( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("data", attrs, children) +} + +/// Used to indicate the term being defined within the context of a definition +/// phrase or sentence. The ancestor <p> element, the <dt>/<dd> pairing, or the +/// nearest section ancestor of the <dfn> element, is considered to be the +/// definition of the term. +/// +pub fn dfn( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("dfn", attrs, children) +} + +/// Marks text that has stress emphasis. The <em> element can be nested, with +/// each nesting level indicating a greater degree of emphasis. +/// +pub fn em( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("em", attrs, children) +} + +/// Represents a range of text that is set off from the normal text for some +/// reason, such as idiomatic text, technical terms, and taxonomical designations, +/// among others. Historically, these have been presented using italicized type, +/// which is the original source of the <i> naming of this element. +/// +pub fn i( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("i", attrs, children) +} + +/// Represents a span of inline text denoting textual user input from a keyboard, +/// voice input, or any other text entry device. By convention, the user agent +/// defaults to rendering the contents of a <kbd> element using its default +/// monospace font, although this is not mandated by the HTML standard. +/// +pub fn kbd( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("kbd", attrs, children) +} + +/// Represents text which is marked or highlighted for reference or notation +/// purposes due to the marked passage's relevance in the enclosing context. +/// +pub fn mark( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("mark", attrs, children) +} + +/// Indicates that the enclosed text is a short inline quotation. Most modern +/// browsers implement this by surrounding the text in quotation marks. This +/// element is intended for short quotations that don't require paragraph +/// breaks; for long quotations use the <blockquote> element. +/// +pub fn q( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("q", attrs, children) +} + +/// Used to provide fall-back parentheses for browsers that do not support the +/// display of ruby annotations using the <ruby> element. One <rp> element +/// should enclose each of the opening and closing parentheses that wrap the +/// <rt> element that contains the annotation's text. +/// +pub fn rp( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("rp", attrs, children) +} + +/// Specifies the ruby text component of a ruby annotation, which is used to +/// provide pronunciation, translation, or transliteration information for East +/// Asian typography. The <rt> element must always be contained within a <ruby> +/// element. +/// +pub fn rt( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("rt", attrs, children) +} + +/// Represents small annotations that are rendered above, below, or next to base +/// text, usually used for showing the pronunciation of East Asian characters. +/// It can also be used for annotating other kinds of text, but this usage is +/// less common. +/// +pub fn ruby( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("ruby", attrs, children) +} + +/// Renders text with a strikethrough, or a line through it. Use the <s> element +/// to represent things that are no longer relevant or no longer accurate. +/// However, <s> is not appropriate when indicating document edits; for that, +/// use the del and ins elements, as appropriate. +/// +pub fn s( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("s", attrs, children) +} + +/// Used to enclose inline text which represents sample (or quoted) output from +/// a computer program. Its contents are typically rendered using the browser's +/// default monospaced font (such as Courier or Lucida Console). +/// +pub fn samp( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("samp", attrs, children) +} + +/// Represents side-comments and small print, like copyright and legal text, +/// independent of its styled presentation. By default, it renders text within +/// it one font size smaller, such as from small to x-small. +/// +pub fn small( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("small", attrs, children) +} + +/// A generic inline container for phrasing content, which does not inherently +/// represent anything. It can be used to group elements for styling purposes +/// (using the class or id attributes), or because they share attribute values, +/// such as lang. It should be used only when no other semantic element is +/// appropriate. <span> is very much like a div element, but div is a block-level +/// element whereas a <span> is an inline-level element. +/// +pub fn span( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("span", attrs, children) +} + +/// Indicates that its contents have strong importance, seriousness, or urgency. +/// Browsers typically render the contents in bold type. +/// +pub fn strong( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("strong", attrs, children) +} + +/// Specifies inline text which should be displayed as subscript for solely +/// typographical reasons. Subscripts are typically rendered with a lowered +/// baseline using smaller text. +/// +pub fn sub( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("sub", attrs, children) +} + +/// Specifies inline text which is to be displayed as superscript for solely +/// typographical reasons. Superscripts are usually rendered with a raised +/// baseline using smaller text. +/// +pub fn sup( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("sup", attrs, children) +} + +/// Represents a specific period in time. It may include the datetime attribute +/// to translate dates into machine-readable format, allowing for better search +/// engine results or custom features such as reminders. +/// +pub fn time( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("time", attrs, children) +} + +/// Represents a span of inline text which should be rendered in a way that +/// indicates that it has a non-textual annotation. This is rendered by default +/// as a simple solid underline but may be altered using CSS. +/// +pub fn u( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("u", attrs, children) +} + +/// Represents the name of a variable in a mathematical expression or a +/// programming context. It's typically presented using an italicized version of +/// the current typeface, although that behavior is browser-dependent. +/// +pub fn var( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("var", attrs, children) +} + +/// Represents the name of a variable in a mathematical expression or a +/// programming context. It's typically presented using an italicized version of +/// the current typeface, although that behavior is browser-dependent. +/// +pub fn wbr(attrs: List(Attribute(msg))) -> Element(msg) { + element("wbr", attrs, []) +} + +// HTML ELEMENTS: IMAGE AND MULTIMEDIA ----------------------------------------- + +/// Represents a word break opportunity—a position within text where the browser +/// may optionally break a line, though its line-breaking rules would not +/// otherwise create a break at that location. +/// +pub fn area(attrs: List(Attribute(msg))) -> Element(msg) { + element("area", attrs, []) +} + +/// Defines an area inside an image map that has predefined clickable areas. An +/// image map allows geometric areas on an image to be associated with hyperlink. +/// +pub fn audio( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("audio", attrs, children) +} + +/// Used to embed sound content in documents. It may contain one or more audio +/// sources, represented using the src attribute or the source element: the +/// browser will choose the most suitable one. It can also be the destination +/// for streamed media, using a MediaStream. +/// +pub fn img(attrs: List(Attribute(msg))) -> Element(msg) { + element("img", attrs, []) +} + +/// Used with <area> elements to define an image map (a clickable link area). +/// +pub fn map( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("map", attrs, children) +} + +/// Used as a child of the media elements, audio and video. It lets you specify +/// timed text tracks (or time-based data), for example to automatically handle +/// subtitles. The tracks are formatted in WebVTT format (.vtt files)—Web Video +/// Text Tracks. +/// +pub fn track(attrs: List(Attribute(msg))) -> Element(msg) { + element("track", attrs, []) +} + +/// Embeds a media player which supports video playback into the document. You +/// can also use <video> for audio content, but the audio element may provide a +/// more appropriate user experience. +/// +pub fn video( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("video", attrs, children) +} + +// HTML ELEMENTS: EMBEDDED CONTENT --------------------------------------------- + +/// Embeds external content at the specified point in the document. This content +///is provided by an external application or other source of interactive content +/// such as a browser plug-in. +/// +pub fn embed(attrs: List(Attribute(msg))) -> Element(msg) { + element("embed", attrs, []) +} + +/// Represents a nested browsing context, embedding another HTML page into the +/// current one. +/// +pub fn iframe(attrs: List(Attribute(msg))) -> Element(msg) { + element("iframe", attrs, []) +} + +/// Represents an external resource, which can be treated as an image, a nested +/// browsing context, or a resource to be handled by a plugin. +/// +pub fn object(attrs: List(Attribute(msg))) -> Element(msg) { + element("object", attrs, []) +} + +/// Contains zero or more <source> elements and one <img> element to offer +/// alternative versions of an image for different display/device scenarios. +/// +pub fn picture( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("picture", attrs, children) +} + +/// Enables the embedding of another HTML page into the current one to enable +/// smoother navigation into new pages. +/// +pub fn portal(attrs: List(Attribute(msg))) -> Element(msg) { + element("portal", attrs, []) +} + +/// Specifies multiple media resources for the picture, the audio element, or +/// the video element. It is a void element, meaning that it has no content and +/// does not have a closing tag. It is commonly used to offer the same media +/// content in multiple file formats in order to provide compatibility with a +/// broad range of browsers given their differing support for image file formats +/// and media file formats. +/// +pub fn source(attrs: List(Attribute(msg))) -> Element(msg) { + element("source", attrs, []) +} + +// HTML ELEMENTS: SVG AND MATHML ----------------------------------------------- + +/// Container defining a new coordinate system and viewport. It is used as the +/// outermost element of SVG documents, but it can also be used to embed an SVG +/// fragment inside an SVG or HTML document. +/// +pub fn svg( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced("http://www.w3.org/2000/svg", "svg", attrs, children) +} + +/// The top-level element in MathML. Every valid MathML instance must be wrapped +/// in it. In addition, you must not nest a second <math> element in another, +/// but you can have an arbitrary number of other child elements in it. +/// +pub fn math( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("math", attrs, children) +} + +// HTML ELEMENTS: SCRIPTING ---------------------------------------------------- + +/// Container element to use with either the canvas scripting API or the WebGL +/// API to draw graphics and animations. +/// +pub fn canvas(attrs: List(Attribute(msg))) -> Element(msg) { + element("canvas", attrs, []) +} + +/// Defines a section of HTML to be inserted if a script type on the page is +/// unsupported or if scripting is currently turned off in the browser. +/// +pub fn noscript( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element("noscript", attrs, children) +} + +/// Used to embed executable code or data; this is typically used to embed or +/// refer to JavaScript code. The <script> element can also be used with other +/// languages, such as WebGL's GLSL shader programming language and JSON. +/// +pub fn script(attrs: List(Attribute(msg)), js: String) -> Element(msg) { + element("script", attrs, [text(js)]) +} + +// HTML ELEMENTS: DEMARCATING EDITS --------------------------------------------- + +/// Represents a range of text that has been deleted from a document. This can +/// be used when rendering "track changes" or source code diff information, for +/// example. The <ins> element can be used for the opposite purpose: to indicate +/// text that has been added to the document. +/// +pub fn del( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("del", attrs, children) +} + +/// Represents a range of text that has been added to a document. You can use the +/// <del> element to similarly represent a range of text that has been deleted +/// from the document. +/// +pub fn ins( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("ins", attrs, children) +} + +// HTML ELEMENTS: TABLE CONTENT ------------------------------------------------ + +/// Specifies the caption (or title) of a table. +/// +pub fn caption( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("caption", attrs, children) +} + +/// Defines a column within a table and is used for defining common semantics on +/// all common cells. It is generally found within a <colgroup> element. +/// +pub fn col(attrs: List(Attribute(msg))) -> Element(msg) { + element.element("col", attrs, []) +} + +/// Defines a group of columns within a table. +/// +pub fn colgroup( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("colgroup", attrs, children) +} + +/// Represents tabular data — that is, information presented in a two-dimensional +/// table comprised of rows and columns of cells containing data. +/// +pub fn table( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("table", attrs, children) +} + +/// Encapsulates a set of table rows (<tr> elements), indicating that they +/// comprise the body of the table (<table>). +/// +pub fn tbody( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("tbody", attrs, children) +} + +/// Defines a cell of a table that contains data. It participates in the table +/// model. +/// +pub fn td( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("td", attrs, children) +} + +/// Defines a set of rows summarizing the columns of the table. +/// +pub fn tfoot( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("tfoot", attrs, children) +} + +/// Defines a cell as a header of a group of table cells. The exact nature of +/// this group is defined by the scope and headers attributes. +/// +pub fn th( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("th", attrs, children) +} + +/// Defines a set of rows defining the head of the columns of the table. +/// +pub fn thead( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("thead", attrs, children) +} + +/// Defines a row of cells in a table. The row's cells can then be established +/// using a mix of <td> (data cell) and <th> (header cell) elements. +/// +pub fn tr( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("tr", attrs, children) +} + +// HTML ELEMENTS: FORMS -------------------------------------------------------- + +/// An interactive element activated by a user with a mouse, keyboard, finger, +/// voice command, or other assistive technology. Once activated, it performs an +/// action, such as submitting a form or opening a dialog. +/// +pub fn button( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("button", attrs, children) +} + +/// Contains a set of <option> elements that represent the permissible or +/// recommended options available to choose from within other controls. +/// +pub fn datalist( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("datalist", attrs, children) +} + +/// Used to group several controls as well as labels (<label>) within a web form. +/// +pub fn fieldset( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("fieldset", attrs, children) +} + +/// Represents a document section containing interactive controls for submitting +/// information. +/// +pub fn form( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("form", attrs, children) +} + +/// Used to create interactive controls for web-based forms to accept data from +/// he user; a wide variety of types of input data and control widgets are +/// available, depending on the device and user agent. The <input> element is +/// one of the most powerful and complex in all of HTML due to the sheer number +/// of combinations of input types and attributes. +/// +pub fn input(attrs: List(Attribute(msg))) -> Element(msg) { + element.element("input", attrs, []) +} + +/// Represents a caption for an item in a user interface. +/// +pub fn label( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("label", attrs, children) +} + +/// Represents a caption for the content of its parent <fieldset>. +/// +pub fn legend( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("legend", attrs, children) +} + +/// Represents either a scalar value within a known range or a fractional value. +/// +pub fn meter( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("meter", attrs, children) +} + +/// Creates a grouping of options within a <select> element. +/// +pub fn optgroup( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("optgroup", attrs, children) +} + +/// Used to define an item contained in a select, an <optgroup>, or a <datalist> +/// element. As such, <option> can represent menu items in popups and other lists +/// of items in an HTML document. +/// +pub fn option(attrs: List(Attribute(msg))) -> Element(msg) { + element.element("option", attrs, []) +} + +/// Container element into which a site or app can inject the results of a +/// calculation or the outcome of a user action. +/// +pub fn output( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("output", attrs, children) +} + +/// Displays an indicator showing the completion progress of a task, typically +/// displayed as a progress bar. +/// +pub fn progress( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("progress", attrs, children) +} + +/// Represents a control that provides a menu of options. +/// +pub fn select( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("select", attrs, children) +} + +/// Represents a multi-line plain-text editing control, useful when you want to +/// allow users to enter a sizeable amount of free-form text, for example, a +/// comment on a review or feedback form. +/// +pub fn textarea(attrs: List(Attribute(msg))) -> Element(msg) { + element.element("textarea", attrs, []) +} + +// HTML ELEMENTS: INTERACTIVE ELEMENTS ----------------------------------------- + +/// Creates a disclosure widget in which information is visible only when the +/// widget is toggled into an "open" state. A summary or label must be provided +/// using the <summary> element. +/// +pub fn details( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("details", attrs, children) +} + +/// Represents a dialog box or other interactive component, such as a dismissible +/// alert, inspector, or subwindow. +/// +pub fn dialog( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("dialog", attrs, children) +} + +/// Specifies a summary, caption, or legend for a details element's disclosure box. +/// Clicking the <summary> element toggles the state of the parent <details> element +/// open and closed. +/// +pub fn summary( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("summary", attrs, children) +} + +// HTML ELEMENTS: WEB COMPONENTS ----------------------------------------------- + +/// Part of the Web Components technology suite, this element is a placeholder +/// inside a web component that you can fill with your own markup, which lets you +/// create separate DOM trees and present them together. +/// +pub fn slot(attrs: List(Attribute(msg))) -> Element(msg) { + element.element("slot", attrs, []) +} + +/// A mechanism for holding HTML that is not to be rendered immediately when a +/// page is loaded but may be instantiated subsequently during runtime using +/// JavaScript. +/// +pub fn template( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + element.element("template", attrs, children) +} diff --git a/lib/src/lustre/element/svg.gleam b/lib/src/lustre/element/svg.gleam new file mode 100644 index 0000000..f9776ca --- /dev/null +++ b/lib/src/lustre/element/svg.gleam @@ -0,0 +1,351 @@ +// IMPORTS --------------------------------------------------------------------- + +import lustre/element.{Element, namespaced, text as inline_text} +import lustre/attribute.{Attribute} + +// CONSTANTS ------------------------------------------------------------------- + +const namespace = "http://www.w3.org/2000/svg" + +// The doc comments (and order) for functions in this module are taken from the +// MDN Element reference: +// +// https://developer.mozilla.org/en-US/docs/Web/SVG/Element +// + +// SVG ELEMENTS: ANIMATION ELEMENTS -------------------------------------------- + +pub fn animate(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "animate", attrs, []) +} + +pub fn animate_motion(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "animateMotion", attrs, []) +} + +pub fn animate_transform(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "animateTransform", attrs, []) +} + +pub fn mpath(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "mpath", attrs, []) +} + +pub fn set(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "set", attrs, []) +} + +// SVG ELEMENTS: BASIC SHAPES -------------------------------------------------- + +pub fn circle(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "circle", attrs, []) +} + +pub fn ellipse(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "ellipse", attrs, []) +} + +pub fn line(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "line", attrs, []) +} + +pub fn polygon(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "polygon", attrs, []) +} + +pub fn polyline(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "polyline", attrs, []) +} + +pub fn rect(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "rect", attrs, []) +} + +// SVG ELEMENTS: CONTAINER ELEMENTS -------------------------------------------- + +pub fn a( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "a", attrs, children) +} + +pub fn defs( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "defs", attrs, children) +} + +pub fn g( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "g", attrs, children) +} + +pub fn marker( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "marker", attrs, children) +} + +pub fn mask( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "mask", attrs, children) +} + +pub fn missing_glyph( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "missing-glyph", attrs, children) +} + +pub fn pattern( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "pattern", attrs, children) +} + +pub fn svg( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "svg", attrs, children) +} + +pub fn switch( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "switch", attrs, children) +} + +pub fn symbol( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "symbol", attrs, children) +} + +// SVG ELEMENTS: DESCRIPTIVE ELEMENTS ------------------------------------------ + +pub fn desc( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "desc", attrs, children) +} + +pub fn metadata( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "metadata", attrs, children) +} + +pub fn title( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "title", attrs, children) +} + +// SVG ELEMENTS: FILTER EFFECTS ------------------------------------------------ + +pub fn fe_blend(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feBlend", attrs, []) +} + +pub fn fe_color_matrix(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feColorMatrix", attrs, []) +} + +pub fn fe_component_transfer(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feComponentTransfer", attrs, []) +} + +pub fn fe_composite(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feComposite", attrs, []) +} + +pub fn fe_convolve_matrix(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feConvolveMatrix", attrs, []) +} + +pub fn fe_diffuse_lighting( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "feDiffuseLighting", attrs, children) +} + +pub fn fe_displacement_map(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feDisplacementMap", attrs, []) +} + +pub fn fe_drop_shadow(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feDropShadow", attrs, []) +} + +pub fn fe_flood(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feFlood", attrs, []) +} + +pub fn fe_func_a(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feFuncA", attrs, []) +} + +pub fn fe_func_b(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feFuncB", attrs, []) +} + +pub fn fe_func_g(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feFuncG", attrs, []) +} + +pub fn fe_func_r(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feFuncR", attrs, []) +} + +pub fn fe_gaussian_blur(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feGaussianBlur", attrs, []) +} + +pub fn fe_image(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feImage", attrs, []) +} + +pub fn fe_merge( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "feMerge", attrs, children) +} + +pub fn fe_merge_node(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feMergeNode", attrs, []) +} + +pub fn fe_morphology(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feMorphology", attrs, []) +} + +pub fn fe_offset(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feOffset", attrs, []) +} + +pub fn fe_specular_lighting( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "feSpecularLighting", attrs, children) +} + +pub fn fe_tile( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "feTile", attrs, children) +} + +pub fn fe_turbulence(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feTurbulence", attrs, []) +} + +// SVG ELEMENTS: GRADIENT ELEMENTS --------------------------------------------- + +pub fn linear_gradient( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "linearGradient", attrs, children) +} + +pub fn radial_gradient( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "radialGradient", attrs, children) +} + +pub fn stop(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "stop", attrs, []) +} + +// SVG ELEMENTS: GRAPHICAL ELEMENTS -------------------------------------------- + +pub fn image(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "image", attrs, []) +} + +pub fn path(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "path", attrs, []) +} + +pub fn text(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "text", attrs, []) +} + +pub fn use_(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "use", attrs, []) +} + +// SVG ELEMENTS: LIGHTING ELEMENTS --------------------------------------------- + +pub fn fe_distant_light(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feDistantLight", attrs, []) +} + +pub fn fe_point_light(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "fePointLight", attrs, []) +} + +pub fn fe_spot_light(attrs: List(Attribute(msg))) -> Element(msg) { + namespaced(namespace, "feSpotLight", attrs, []) +} + +// SVG ELEMENTS: NEVER-RENDERED ELEMENTS --------------------------------------- + +pub fn clip_path( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "clipPath", attrs, children) +} + +pub fn script(attrs: List(Attribute(msg)), js: String) -> Element(msg) { + namespaced(namespace, "script", attrs, [inline_text(js)]) +} + +pub fn style(attrs: List(Attribute(msg)), css: String) -> Element(msg) { + namespaced(namespace, "style", attrs, [inline_text(css)]) +} + +// SVG ELEMENTS: RENDERABLE ELEMENTS ------------------------------------------- + +pub fn foreign_object( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "foreignObject", attrs, children) +} + +pub fn text_path( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "textPath", attrs, children) +} + +pub fn tspan( + attrs: List(Attribute(msg)), + children: List(Element(msg)), +) -> Element(msg) { + namespaced(namespace, "tspan", attrs, children) +} diff --git a/lib/src/lustre/event.gleam b/lib/src/lustre/event.gleam new file mode 100644 index 0000000..8ee2f4f --- /dev/null +++ b/lib/src/lustre/event.gleam @@ -0,0 +1,184 @@ +//// + +// IMPORTS --------------------------------------------------------------------- + +import gleam/dynamic.{DecodeError, Dynamic} +import gleam/option.{None, Option, Some} +import gleam/result +import lustre/attribute.{Attribute} +import lustre/effect.{Effect} + +// TYPES ----------------------------------------------------------------------- + +type Decoded(a) = + Result(a, List(DecodeError)) + +// EFFECTS --------------------------------------------------------------------- + +@target(javascript) +@external(javascript, "../lustre.ffi.mjs", "emit") +pub fn emit(event: String, data: any) -> Effect(msg) + +// CUSTOM EVENTS --------------------------------------------------------------- + +pub fn on(name: String, handler: fn(Dynamic) -> Option(msg)) -> Attribute(msg) { + attribute.on(name, handler) +} + +// MOUSE EVENTS ---------------------------------------------------------------- + +/// +pub fn on_click(msg: msg) -> Attribute(msg) { + use _ <- on("click") + Some(msg) +} + +/// +pub fn on_mouse_down(msg: msg) -> Attribute(msg) { + use _ <- on("mousedown") + Some(msg) +} + +/// +pub fn on_mouse_up(msg: msg) -> Attribute(msg) { + use _ <- on("mouseup") + Some(msg) +} + +/// +pub fn on_mouse_enter(msg: msg) -> Attribute(msg) { + use _ <- on("mouseenter") + Some(msg) +} + +/// +pub fn on_mouse_leave(msg: msg) -> Attribute(msg) { + use _ <- on("mouseleave") + Some(msg) +} + +/// +pub fn on_mouse_over(msg: msg) -> Attribute(msg) { + use _ <- on("mouseover") + Some(msg) +} + +/// +pub fn on_mouse_out(msg: msg) -> Attribute(msg) { + use _ <- on("mouseout") + Some(msg) +} + +// KEYBOARD EVENTS ------------------------------------------------------------- + +/// Listens for key presses on an element, and dispatches a message with the +/// current key being pressed. +/// +pub fn on_keypress(msg: fn(String) -> msg) -> Attribute(msg) { + use event <- on("keypress") + + case dynamic.field("key", dynamic.string)(event) { + Ok(key) -> Some(msg(key)) + Error(_) -> None + } +} + +/// Listens for key dow events on an element, and dispatches a message with the +/// current key being pressed. +/// +pub fn on_keydown(msg: fn(String) -> msg) -> Attribute(msg) { + use event <- on("keydown") + + case dynamic.field("key", dynamic.string)(event) { + Ok(key) -> Some(msg(key)) + Error(_) -> None + } +} + +/// Listens for key up events on an element, and dispatches a message with the +/// current key being released. +/// +pub fn on_keyup(msg: fn(String) -> msg) -> Attribute(msg) { + use event <- on("keyup") + + case dynamic.field("key", dynamic.string)(event) { + Ok(key) -> Some(msg(key)) + Error(_) -> None + } +} + +// FORM EVENTS ----------------------------------------------------------------- + +/// +pub fn on_input(msg: fn(String) -> msg) -> Attribute(msg) { + use event <- on("input") + + case value(event) { + Ok(val) -> Some(msg(val)) + Error(_) -> None + } +} + +pub fn on_check(msg: fn(Bool) -> msg) -> Attribute(msg) { + use event <- on("change") + + case checked(event) { + Ok(val) -> Some(msg(val)) + Error(_) -> None + } +} + +pub fn on_submit(msg: msg) -> Attribute(msg) { + use _ <- on("submit") + Some(msg) +} + +// FOCUS EVENTS ---------------------------------------------------------------- + +pub fn on_focus(msg: msg) -> Attribute(msg) { + use _ <- on("focus") + Some(msg) +} + +pub fn on_blur(msg: msg) -> Attribute(msg) { + use _ <- on("blur") + Some(msg) +} + +// DECODERS -------------------------------------------------------------------- + +/// A helpful decoder to extract the `value` from an event object. This is handy +/// for getting the value as a string from an input event, for example. +/// +pub fn value(event: Dynamic) -> Decoded(String) { + event + |> dynamic.field("target", dynamic.field("value", dynamic.string)) +} + +/// A helpful decoder to extract the `checked` property from an event triggered +/// by a checkbox. +/// +pub fn checked(event: Dynamic) -> Decoded(Bool) { + event + |> dynamic.field("target", dynamic.field("checked", dynamic.bool)) +} + +/// A helpful decoder to grab the mouse's current x and y position in the +/// viewport from an event object. +/// +pub fn mouse_position(event: Dynamic) -> Decoded(#(Float, Float)) { + use x <- result.then(dynamic.field("clientX", dynamic.float)(event)) + use y <- result.then(dynamic.field("clientY", dynamic.float)(event)) + + Ok(#(x, y)) +} + +// UTILS ----------------------------------------------------------------------- + +@target(javascript) +@external(javascript, "../lustre.ffi.mjs", "prevent_default") +pub fn prevent_default(event: Dynamic) -> Nil + +@target(javascript) +@external(javascript, "../lustre.ffi.mjs", "stop_propagation") +pub fn stop_propagation(event: Dynamic) -> Nil diff --git a/lib/src/runtime.ffi.mjs b/lib/src/runtime.ffi.mjs new file mode 100644 index 0000000..7325583 --- /dev/null +++ b/lib/src/runtime.ffi.mjs @@ -0,0 +1,230 @@ +import { element, namespaced, text } from "./lustre/element.mjs"; +import { List, Empty } from "./gleam.mjs"; +import { Some, None } from "../gleam_stdlib/gleam/option.mjs"; + +const Element = element("").constructor; +const ElementNs = namespaced("", "").constructor; +const Text = text("").constructor; + +export function morph(prev, curr, parent) { + if (curr instanceof ElementNs) + return prev?.nodeType === 1 && + prev.nodeName === curr[0].toUpperCase() && + prev.namespaceURI === curr[3] + ? morphElement(prev, curr, curr[3], parent) + : createElement(prev, curr, curr[3], parent); + + if (curr instanceof Element) { + return prev?.nodeType === 1 && prev.nodeName === curr[0].toUpperCase() + ? morphElement(prev, curr, null, parent) + : createElement(prev, curr, null, parent); + } + + if (curr instanceof Text) { + return prev?.nodeType === 3 + ? morphText(prev, curr) + : createText(prev, curr); + } + + return document.createComment( + [ + "[internal lustre error] I couldn't work out how to render this element. This", + "function should only be called internally by lustre's runtime: if you think", + "this is an error, please open an issue at", + "https://github.come/hayleigh-dot-dev/lustre/issues/new.", + ].join(" ") + ); +} + +// ELEMENTS -------------------------------------------------------------------- + +function createElement(prev, curr, ns, parent = null) { + const el = ns + ? document.createElementNS(ns, curr[0]) + : document.createElement(curr[0]); + + let attr = curr[1]; + while (attr.head) { + morphAttr(el, attr.head[0], attr.head[1]); + attr = attr.tail; + } + + if (customElements.get(curr[0])) { + el._slot = curr[2]; + } else if (curr[0] === "slot") { + let child = new Empty(); + let parentWithSlot = parent; + + while (parentWithSlot) { + if (parentWithSlot._slot) { + child = parentWithSlot._slot; + break; + } else { + parentWithSlot = parentWithSlot.parentNode; + } + } + + while (child.head) { + el.appendChild(morph(null, child.head, el)); + child = child.tail; + } + } else { + let child = curr[2]; + while (child.head) { + el.appendChild(morph(null, child.head, el)); + child = child.tail; + } + + if (prev) prev.replaceWith(el); + } + return el; +} + +function morphElement(prev, curr, ns, parent) { + const prevAttrs = prev.attributes; + const currAttrs = new Map(); + + let currAttr = curr[1]; + while (currAttr.head) { + currAttrs.set(currAttr.head[0], currAttr.head[1]); + currAttr = currAttr.tail; + } + + for (const { name, value: prevValue } of prevAttrs) { + if (!currAttrs.has(name)) { + prev.removeAttribute(name); + } else { + const value = currAttrs.get(name); + + if (value !== prevValue) { + morphAttr(prev, name, value); + currAttrs.delete(name); + } + } + } + + for (const [name, value] of currAttrs) { + morphAttr(prev, name, value); + } + + if (customElements.get(curr[0])) { + prev._slot = curr[2]; + } else if (curr[0] === "slot") { + let prevChild = prev.firstChild; + let currChild = new Empty(); + let parentWithSlot = parent; + + while (parentWithSlot) { + if (parentWithSlot._slot) { + currChild = parentWithSlot._slot; + break; + } else { + parentWithSlot = parentWithSlot.parentNode; + } + } + + while (prevChild) { + if (currChild.head) { + morph(prevChild, currChild.head, prev); + currChild = currChild.tail; + } + + prevChild = prevChild.nextSibling; + } + + while (currChild.head) { + prev.appendChild(morph(null, currChild.head, prev)); + currChild = currChild.tail; + } + } else { + let prevChild = prev.firstChild; + let currChild = curr[2]; + + while (prevChild) { + if (currChild.head) { + const next = prevChild.nextSibling; + morph(prevChild, currChild.head, prev); + currChild = currChild.tail; + prevChild = next; + } else { + const next = prevChild.nextSibling; + prevChild.remove(); + prevChild = next; + } + } + + while (currChild.head) { + prev.appendChild(morph(null, currChild.head, prev)); + currChild = currChild.tail; + } + } + + return prev; +} + +// ATTRIBUTES ------------------------------------------------------------------ + +function morphAttr(el, name, value) { + switch (typeof value) { + case "string": + el.setAttribute(name, value); + break; + + // Boolean attributes work a bit differently in HTML. Their presence always + // implies true: to set an attribute to false you need to remove it entirely. + case "boolean": + value ? el.setAttribute(name, name) : el.removeAttribute(name); + break; + + // Event listeners need to be handled slightly differently because we need + // to be able to support custom events. We + case name.startsWith("on") && "function": { + const event = name.slice(2).toLowerCase(); + + if (el[`_${name}`] === value) break; + + el.removeEventListener(event, el[`_${name}`]); + el.addEventListener(event, value); + el[`_${name}`] = value; + break; + } + + default: { + el[name] = toJsValue(value); + } + } +} + +function toJsValue(value) { + if (value instanceof List) { + return value.toArray().map(toJsValue); + } + + if (value instanceof Some) { + return toJsValue(value[0]); + } + + if (value instanceof None) { + return null; + } + + return value; +} + +// TEXT ------------------------------------------------------------------------ + +function createText(prev, curr) { + const el = document.createTextNode(curr[0]); + + if (prev) prev.replaceWith(el); + return el; +} + +function morphText(prev, curr) { + const prevValue = prev.nodeValue; + const currValue = curr[0]; + + if (prevValue !== currValue) prev.nodeValue = currValue; + + return prev; +} diff --git a/lib/test/examples/components.gleam b/lib/test/examples/components.gleam new file mode 100644 index 0000000..722b796 --- /dev/null +++ b/lib/test/examples/components.gleam @@ -0,0 +1,102 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/dynamic +import gleam/int +import gleam/list +import gleam/map +import gleam/option.{Some} +import gleam/result +import lustre +import lustre/attribute +import lustre/effect +import lustre/element.{element, text} +import lustre/element/html.{button, div, li, ol, p, slot} +import lustre/event + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + let assert Ok(_) = + lustre.component( + "custom-counter", + counter_init, + counter_update, + counter_render, + map.from_list([ + #( + "count", + fn(attr) { + dynamic.int(attr) + |> result.map(GotCount) + }, + ), + ]), + ) + + // A `simple` lustre application doesn't produce `Effect`s. These are best to + // start with if you're just getting started with lustre or you know you don't + // need the runtime to manage any side effects. + let app = lustre.simple(init, update, render) + let assert Ok(_) = lustre.start(app, "[data-lustre-app]") + + Nil +} + +fn init() { + [] +} + +fn update(history, msg) { + case msg { + "reset" -> [] + _ -> [msg, ..history] + } +} + +fn render(history) { + let on_custom_click = { + use _ <- event.on("custom-click") + Some("click") + } + div( + [], + [ + button([event.on_click("reset")], [text("Reset")]), + ol([], list.map(history, fn(msg) { li([], [text(msg)]) })), + element( + "custom-counter", + [on_custom_click, attribute.property("count", list.length(history))], + [ol([], list.map(history, fn(msg) { li([], [text(msg)]) }))], + ), + ], + ) +} + +// COUNTER --------------------------------------------------------------------- + +fn counter_init() { + #(0, effect.none()) +} + +type CounterMsg { + GotCount(Int) + Clicked +} + +fn counter_update(count, msg) { + case msg { + GotCount(count) -> #(count, effect.none()) + Clicked -> #(count, event.emit("custom-click", Nil)) + } +} + +fn counter_render(count) { + div( + [], + [ + button([event.on_click(Clicked)], [text("Click me!")]), + p([], [text("Count: "), text(int.to_string(count))]), + slot([]), + ], + ) +} diff --git a/lib/test/examples/components.html b/lib/test/examples/components.html new file mode 100644 index 0000000..f4b4530 --- /dev/null +++ b/lib/test/examples/components.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>lustre | components</title> + + <script type="module"> + import { main } from "../../build/dev/javascript/lustre/examples/components.mjs"; + + document.addEventListener("DOMContentLoaded", main); + </script> + </head> + <body> + <div data-lustre-app></div> + </body> +</html> diff --git a/lib/test/examples/counter.gleam b/lib/test/examples/counter.gleam new file mode 100644 index 0000000..759ebdf --- /dev/null +++ b/lib/test/examples/counter.gleam @@ -0,0 +1,56 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/int +import lustre +import lustre/element.{Element, text} +import lustre/element/html.{button, div, p} +import lustre/event + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + // A `simple` lustre application doesn't produce `Effect`s. These are best to + // start with if you're just getting started with lustre or you know you don't + // need the runtime to manage any side effects. + let app = lustre.simple(init, update, render) + let assert Ok(_) = lustre.start(app, "[data-lustre-app]") +} + +// MODEL ----------------------------------------------------------------------- + +pub type Model = + Int + +pub fn init() -> Model { + 0 +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + Incr + Decr + Reset +} + +pub fn update(model: Model, msg: Msg) -> Model { + case msg { + Incr -> model + 1 + Decr -> model - 1 + Reset -> 0 + } +} + +// VIEW ------------------------------------------------------------------------ + +pub fn render(model: Model) -> Element(Msg) { + div( + [], + [ + button([event.on_click(Incr)], [text("+")]), + button([event.on_click(Decr)], [text("-")]), + button([event.on_click(Reset)], [text("Reset")]), + p([], [text(int.to_string(model))]), + ], + ) +} diff --git a/lib/test/examples/counter.html b/lib/test/examples/counter.html new file mode 100644 index 0000000..2b120fd --- /dev/null +++ b/lib/test/examples/counter.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>lustre | counter</title> + + <script type="module"> + import { main } from "../../build/dev/javascript/lustre/examples/counter.mjs"; + + document.addEventListener("DOMContentLoaded", main); + </script> + </head> + <body> + <div data-lustre-app></div> + </body> +</html> diff --git a/lib/test/examples/index.html b/lib/test/examples/index.html new file mode 100644 index 0000000..70d4196 --- /dev/null +++ b/lib/test/examples/index.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>lustre | examples</title> + </head> + <body> + <menu> + <li> + <a href="input.html">input</a> + </li> + <li> + <a href="counter.html">counter</a> + </li> + <li> + <a href="nested.html">nested</a> + </li> + <li> + <a href="svg.html">svg</a> + </li> + <li> + <a href="components.html">components</a> + </li> + </menu> + </body> +</html> diff --git a/lib/test/examples/input.gleam b/lib/test/examples/input.gleam new file mode 100644 index 0000000..d59c0c9 --- /dev/null +++ b/lib/test/examples/input.gleam @@ -0,0 +1,132 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/dynamic +import gleam/string +import lustre +import lustre/attribute.{attribute} +import lustre/element.{Element, text} +import lustre/element/html.{div, input, label, pre} +import lustre/event + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + // A `simple` lustre application doesn't produce `Effect`s. These are best to + // start with if you're just getting started with lustre or you know you don't + // need the runtime to manage any side effects. + let app = lustre.simple(init, update, render) + let assert Ok(_) = lustre.start(app, "[data-lustre-app]") + + Nil +} + +// MODEL ----------------------------------------------------------------------- + +type Model { + Model(email: String, password: String, remember_me: Bool) +} + +fn init() -> Model { + Model(email: "", password: "", remember_me: False) +} + +// UPDATE ---------------------------------------------------------------------- + +type Msg { + Typed(Input, String) + Toggled(Control, Bool) +} + +type Input { + Email + Password +} + +type Control { + RememberMe +} + +fn update(model: Model, msg: Msg) -> Model { + case msg { + Typed(Email, email) -> Model(..model, email: email) + Typed(Password, password) -> Model(..model, password: password) + Toggled(RememberMe, remember_me) -> Model(..model, remember_me: remember_me) + } +} + +// RENDER ---------------------------------------------------------------------- + +fn render(model: Model) -> Element(Msg) { + div( + [attribute.class("container")], + [ + card([ + email_input(model.email), + password_input(model.password), + remember_checkbox(model.remember_me), + pre( + [attribute.class("debug")], + [ + string.inspect(model) + |> string.replace("(", "(\n ") + |> string.replace(", ", ",\n ") + |> string.replace(")", "\n)") + |> text, + ], + ), + ]), + ], + ) +} + +fn card(content: List(Element(a))) -> Element(a) { + div([attribute.class("card")], [div([], content)]) +} + +fn email_input(value: String) -> Element(Msg) { + render_input(Email, "email", "email-input", value, "Email address") +} + +fn password_input(value: String) -> Element(Msg) { + render_input(Password, "password", "password-input", value, "Password") +} + +fn render_input( + field: Input, + type_: String, + id: String, + value: String, + label_: String, +) -> Element(Msg) { + div( + [attribute.class("input")], + [ + label([attribute.for(id)], [text(label_)]), + input([ + attribute.id(id), + attribute.name(id), + attribute.type_(type_), + attribute.required(True), + attribute.value(dynamic.from(value)), + event.on_input(fn(value) { Typed(field, value) }), + ]), + ], + ) +} + +fn remember_checkbox(checked: Bool) -> Element(Msg) { + div( + [attribute.class("flex items-center")], + [ + input([ + attribute.id("remember-me"), + attribute.name("remember-me"), + attribute.type_("checkbox"), + attribute.checked(checked), + attribute.class("checkbox"), + event.on_click(Toggled(RememberMe, !checked)), + ]), + label([attribute.for("remember-me")], [text("Remember me")]), + ], + ) +} diff --git a/lib/test/examples/input.html b/lib/test/examples/input.html new file mode 100644 index 0000000..3bd6463 --- /dev/null +++ b/lib/test/examples/input.html @@ -0,0 +1,54 @@ +<!DOCTYPE html> +<html lang="en" class="h-full bg-gray-50"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>lustre | forms</title> + + <script src="https://cdn.tailwindcss.com"></script> + <style type="text/tailwindcss"> + @layer components { + .container { + @apply flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8; + } + + .card { + @apply mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]; + } + + .card > div { + @apply bg-white px-6 py-12 shadow sm:rounded-lg sm:px-12 space-y-6; + } + + .debug { + @apply text-sm text-gray-400; + } + + .input label { + @apply block text-sm font-medium leading-6 text-gray-900; + } + + .input input { + @apply block w-full rounded-md border-0 p-1.5 mt-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-pink-600 sm:text-sm sm:leading-6; + } + + .checkbox { + @apply h-4 w-4 rounded border-gray-300 text-pink-600 focus:ring-pink-600; + } + + .checkbox + label { + @apply ml-3 block text-sm leading-6 text-gray-900; + } + } + </style> + + <script type="module"> + import { main } from "../../build/dev/javascript/lustre/examples/input.mjs"; + + document.addEventListener("DOMContentLoaded", main); + </script> + </head> + <body class="h-full"> + <div data-lustre-app></div> + </body> +</html> diff --git a/lib/test/examples/nested.gleam b/lib/test/examples/nested.gleam new file mode 100644 index 0000000..47bb9d5 --- /dev/null +++ b/lib/test/examples/nested.gleam @@ -0,0 +1,57 @@ +// IMPORTS --------------------------------------------------------------------- + +import examples/counter +import gleam/list +import gleam/map.{Map} +import gleam/pair +import lustre +import lustre/element.{Element} +import lustre/element/html.{div} + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + // A `simple` lustre application doesn't produce `Effect`s. These are best to + // start with if you're just getting started with lustre or you know you don't + // need the runtime to manage any side effects. + let app = lustre.simple(init, update, render) + let assert Ok(_) = lustre.start(app, "[data-lustre-app]") + + Nil +} + +// MODEL ----------------------------------------------------------------------- + +type Model = + Map(Int, counter.Model) + +fn init() -> Model { + use counters, id <- list.fold(list.range(1, 10), map.new()) + + map.insert(counters, id, counter.init()) +} + +// UPDATE ---------------------------------------------------------------------- + +type Msg = + #(Int, counter.Msg) + +fn update(model: Model, msg: Msg) -> Model { + let #(id, counter_msg) = msg + let assert Ok(counter) = map.get(model, id) + + map.insert(model, id, counter.update(counter, counter_msg)) +} + +// RENDER ---------------------------------------------------------------------- + +fn render(model: Model) -> Element(Msg) { + let counters = { + use rest, id, counter <- map.fold(model, []) + let el = element.map(counter.render(counter), pair.new(id, _)) + + [el, ..rest] + } + + div([], counters) +} diff --git a/lib/test/examples/nested.html b/lib/test/examples/nested.html new file mode 100644 index 0000000..420b159 --- /dev/null +++ b/lib/test/examples/nested.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>lustre | nested</title> + + <script type="module"> + import { main } from "../../build/dev/javascript/lustre/examples/nested.mjs"; + + document.addEventListener("DOMContentLoaded", main); + </script> + </head> + <body> + <div data-lustre-app></div> + </body> +</html> diff --git a/lib/test/examples/svg.gleam b/lib/test/examples/svg.gleam new file mode 100644 index 0000000..c1cc5fb --- /dev/null +++ b/lib/test/examples/svg.gleam @@ -0,0 +1,107 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/int +import lustre +import lustre/attribute.{attribute} +import lustre/element.{Element, text} +import lustre/element/html.{button, div, p, svg} +import lustre/element/svg.{path} +import lustre/event + +// MAIN ------------------------------------------------------------------------ + +pub fn main() { + // A `simple` lustre application doesn't produce `Effect`s. These are best to + // start with if you're just getting started with lustre or you know you don't + // need the runtime to manage any side effects. + let app = lustre.simple(init, update, render) + let assert Ok(_) = lustre.start(app, "[data-lustre-app]") +} + +// MODEL ----------------------------------------------------------------------- + +pub type Model = + Int + +pub fn init() -> Model { + 0 +} + +// UPDATE ---------------------------------------------------------------------- + +pub opaque type Msg { + Incr + Decr + Reset +} + +pub fn update(model: Model, msg: Msg) -> Model { + case msg { + Incr -> model + 1 + Decr -> model - 1 + Reset -> 0 + } +} + +// VIEW ------------------------------------------------------------------------ + +pub fn render(model: Model) -> Element(Msg) { + div( + [], + [ + button( + [event.on_click(Incr)], + [plus([attribute.style([#("color", "red")])])], + ), + button([event.on_click(Decr)], [minus([])]), + button([event.on_click(Reset)], [text("Reset")]), + p([], [text(int.to_string(model))]), + ], + ) +} + +fn plus(attrs) { + svg( + [ + attribute("width", "15"), + attribute("height", "15"), + attribute("viewBox", "0 0 15 15"), + attribute("fill", "none"), + ..attrs + ], + [ + path([ + attribute( + "d", + "M8 2.75C8 2.47386 7.77614 2.25 7.5 2.25C7.22386 2.25 7 2.47386 7 2.75V7H2.75C2.47386 7 2.25 7.22386 2.25 7.5C2.25 7.77614 2.47386 8 2.75 8H7V12.25C7 12.5261 7.22386 12.75 7.5 12.75C7.77614 12.75 8 12.5261 8 12.25V8H12.25C12.5261 8 12.75 7.77614 12.75 7.5C12.75 7.22386 12.5261 7 12.25 7H8V2.75Z", + ), + attribute("fill", "currentColor"), + attribute("fill-rule", "evenodd"), + attribute("clip-rule", "evenodd"), + ]), + ], + ) +} + +fn minus(attrs) { + svg( + [ + attribute("width", "15"), + attribute("height", "15"), + attribute("viewBox", "0 0 15 15"), + attribute("fill", "none"), + ..attrs + ], + [ + path([ + attribute( + "d", + "M2.25 7.5C2.25 7.22386 2.47386 7 2.75 7H12.25C12.5261 7 12.75 7.22386 12.75 7.5C12.75 7.77614 12.5261 8 12.25 8H2.75C2.47386 8 2.25 7.77614 2.25 7.5Z", + ), + attribute("fill", "currentColor"), + attribute("fill-rule", "evenodd"), + attribute("clip-rule", "evenodd"), + ]), + ], + ) +} diff --git a/lib/test/examples/svg.html b/lib/test/examples/svg.html new file mode 100644 index 0000000..12af526 --- /dev/null +++ b/lib/test/examples/svg.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>lustre | svg</title> + + <script type="module"> + import { main } from "../../build/dev/javascript/lustre/examples/svg.mjs"; + + document.addEventListener("DOMContentLoaded", main); + </script> + </head> + <body> + <div data-lustre-app></div> + </body> +</html> |