Current section
Files
Jump to
Current section
Files
src/off_topic.gleam
//// <style>
//// small {
//// font-size: 0.7em;
//// opacity: 0.75;
//// }
//// .goto-top {
//// display: block;
//// font-size: 0.85em;
//// text-align: right;
//// }
//// h4 {
//// margin-bottom: 0;
//// + p {
//// margin-top: 0;
//// }
//// }
//// *, ::before, ::after {
//// --tw-border-style: solid;
//// --tw-drop-shadow-alpha: 100%;
//// }
//// </style>
//// <script src="https://off-topic-204b42.gitlab.io/component.js"></script>
//// <script>
//// (callback => document.readyState !== 'loading' ? callback() : document.addEventListener('DOMContentLoaded', callback, { once: true }))(() => {
//// const list = document.querySelector('.sidebar > ul:last-of-type')
//// const sortedLists = document.createDocumentFragment()
//// const sortedMembers = document.createDocumentFragment()
//// for (const header of document.querySelectorAll('main > h4')) {
//// sortedLists.append((() => {
//// const node = document.createElement('h3')
//// node.append(header.textContent)
//// return node
//// })())
//// sortedMembers.append((() => {
//// const node = document.createElement('h2')
//// node.append(header.textContent)
//// return node
//// })())
//// const sortedList = document.createElement('ul')
//// sortedLists.append(sortedList)
//// for (const anchor of header.nextElementSibling.querySelectorAll('a')) {
//// const href = anchor.getAttribute('href')
//// const member = document.querySelector(`.member:has(h2 > a[href="${href}"])`)
//// const sidebar = list.querySelector(`li:has(a[href="${href}"])`)
//// sortedList.append(sidebar)
//// sortedMembers.append(member)
//// }
//// }
//// document.querySelector('.sidebar').insertBefore(sortedLists, list)
//// document.querySelector('.module-members:has(#module-values)').insertBefore(sortedMembers, document.querySelector('#module-values').nextSibling)
//// const goToTop = document.createElement('a')
//// goToTop.classList.add('goto-top')
//// goToTop.setAttribute('href', '#')
//// goToTop.textContent = 'Back to top ↑'
//// for (const member of document.querySelectorAll('.member')) {
//// const id = member.querySelector('h2')?.id
//// if (id) {
//// const tag = 'ot-demo-' + id.replace(/_/g, '-')
//// if (customElements.get(tag)) {
//// const demo = document.createElement(tag)
//// member.append(demo)
//// } else {
//// member.append(goToTop.cloneNode(true))
//// }
//// }
//// }
//// })
//// </script>
////
//// **Tip:** Hover the links for short summaries!
////
//// #### Starting apps
//// [application](#application "Create a Lustre application with subscriptions"),
//// [component](#component "Create a Lustre component with subscriptions"),
//// [script](#script "Inline the server component runtime as a script tag")
////
//// #### Making subscriptions
//// [none](#none "A subscription that never fires"),
//// [from](#from "Build a subscription from an arbitrary setup callback"),
//// [element](#element "Build a subscription with access to the root element"),
//// [batch](#batch "Combine multiple subscriptions into one"),
//// [dep](#dep "Wrap a value as a subscription dependency"),
//// [map](#map "Transform the messages a subscription dispatches"),
//// [watch](#watch "Run a side effect whenever dependencies change")
////
//// #### Keyboard
//// [on_key_press](#on_key_press "Subscribe to keypress events"),
//// [on_key_down](#on_key_down "Subscribe to keydown events"),
//// [on_key_up](#on_key_up "Subscribe to keyup events")
////
//// #### Pointer
//// [on_click](#on_click "Subscribe to click events"),
//// [on_pointer_move](#on_pointer_move "Subscribe to pointer move events"),
//// [on_pointer_down](#on_pointer_down "Subscribe to pointer down events"),
//// [on_pointer_up](#on_pointer_up "Subscribe to pointer up events"),
//// [on_pointer_cancel](#on_pointer_cancel "Subscribe to pointer cancel events"),
//// [on_wheel](#on_wheel "Subscribe to wheel events")
////
//// #### Browser
//// [window_size](#window_size "Dispatch the current window size"),
//// [screen_orientation](#screen_orientation "Dispatch the current screen orientation"),
//// [window_scroll](#window_scroll "Dispatch the current scroll position"),
//// [scroll_to](#scroll_to "Scroll the window to a position"),
//// [page_state](#page_state "Dispatch the current page lifecycle state"),
//// [window_hover](#window_hover "Dispatch the current window hover state"),
//// [title](#title "Update the document title"),
//// [meta](#meta "Upsert a document meta tag"),
//// [body_class](#body_class "Set a class on the body tag"),
//// [prevent_unload](#prevent_unload "Block the browser unload dialog")
////
//// #### DOM
//// [element_size](#element_size "Dispatch element dimensions on resize"),
//// [element_scroll](#element_scroll "Dispatch element scroll position"),
//// [element_scroll_to](#element_scroll_to "Scroll an element to a position"),
//// [scroll_into_view](#scroll_into_view "Scroll an element into view"),
//// [element_visible](#element_visible "Dispatch element viewport visibility")
//// [focus](#focus "Focus an element by CSS selector"),
//// [blur](#blur "Blur the currently focused element"),
//// [on](#on "Subscribe to a DOM event on a target element")
////
//// #### Component
//// [aria](#aria "Sync an ARIA property on the component host"),
//// [pseudo_state](#pseudo_state "Sync a CSS custom state on the component host"),
//// [form_value](#form_value "Sync the form-associated element value")
////
//// #### Preferences
//// [colour_scheme](#colour_scheme "Dispatch the current color scheme preference"),
//// [contrast](#contrast "Dispatch the current contrast preference"),
//// [reduced_motion](#reduced_motion "Dispatch the current motion preference"),
//// [breakpoints](#breakpoints "Dispatch the active breakpoint from a list"),
//// [tailwind_breakpoints](#tailwind_breakpoints "Dispatch the active breakpoint using the Tailwind definitions"),
//// [language](#language "Dispatch the current navigator language"),
//// [local_storage](#local_storage "Dispatch a local storage value and its changes"),
//// [session_storage](#session_storage "Dispatch a session storage value and its changes"),
//// [set_local_storage](#set_local_storage "Write a localStorage key"),
//// [set_session_storage](#set_session_storage "Write a sessionStorage key")
////
//// #### Time
//// [now](#now "Dispatch the current timestamp"),
//// [here](#here "Dispatch the current browser timezone offset"),
//// [after](#after "Dispatch a message after a delay"),
//// [every](#every "Dispatch a message on a repeating interval"),
//// [on_idle](#on_idle "Dispatch a message when the user stops interacting with the page for some time"),
//// [on_animation_frame](#on_animation_frame "Dispatch the current timestamp on every animation frame"),
//// [on_animation_frame_delta](#on_animation_frame_delta "Dispatch the elapsed time since the last animation frame")
////
//// #### Connections
//// [online](#online "Dispatch the current network status"),
//// [server_sent_events](#server_sent_events "Subscribe to a Server-Sent Events stream"),
//// [websocket](#websocket "Subscribe to a WebSocket connection"),
//// [websocket_send](#websocket_send "Send a frame over a WebSocket connection")
////
//// #### Advanced
//// [init](#init "Run the application init function and start subscriptions"),
//// [update](#update "Handle a message in the subscription runtime"),
//// [view](#view "Render the application view with the subscription runtime")
//// [remote](#remote "Build a subscription that runs on the server component client"),
//// [command](#command "Dispatch a browser command via the off_topic client runtime"),
//// [watching](#watching "Add dependencies to a subscription"),
//// [including](#including "Specify which event fields to include in a remote subscription"),
//// [throttle](#throttle "Limit how often a subscription fires"),
//// [delay](#delay "Delay dispatching after the last event in a burst"),
//// [root](#root "CSS selector for the component mount point")
import gleam/bool
import gleam/dynamic/decode.{type Decoder, type Dynamic}
import gleam/float
import gleam/int
import gleam/json.{type Json}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/time/duration.{type Duration}
import gleam/time/timestamp.{type Timestamp}
import lustre.{type App}
import lustre/attribute
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import off_topic/component
import off_topic/internal/commands
import off_topic/internal/runtime
import off_topic/internal/subscriptions
// -- FUNDAMENTALS ------------------------------------------------------------
/// An active listener that dispatches messages to your Lustre application.
///
/// Build subscriptions with the functions in this module and return them from
/// your application's `subscriptions` callback. Use [batch](#batch) to combine
/// multiple subscriptions, and [none](#none) to return an empty one.
pub type Subscription(message) =
runtime.Subscription(message)
/// An opaque value off_topic uses to detect when a subscription's inputs have
/// changed and it needs to be restarted.
///
/// Create one with [dep](#dep).
pub type Dependency =
runtime.Dependency
/// The subscription runtime's wrapper around your application model.
///
/// Use with [init](#init), [update](#update), and [view](#view) when wiring
/// up off_topic manually instead of through [application](#application).
pub type Model(model, message) =
runtime.Model(model, message)
/// The subscription runtime's wrapper around your application message type.
///
/// Use with [init](#init), [update](#update), and [view](#view) when wiring
/// up off_topic manually instead of through [application](#application).
pub type Message(message) =
runtime.Message(message)
/// CSS selector for the component mount point.
///
/// Pass `root` as the `selector` argument to any element-scoped subscription
/// to target the Lustre component mount element.
///
/// See also: [target](#target), [element_size](#element_size),
/// [element_scroll](#element_scroll), [element_visible](#element_visible)
pub const root = ":scope"
// -- STARTING APPS -----------------------------------------------------------
/// Create a Lustre application with subscription support.
///
/// A drop-in replacement for `lustre.application` that adds a `subscriptions`
/// callback. The returned `App` is passed directly to `lustre.start` or
/// `lustre.start_server_component`.
///
/// See also: [component](#component), [init](#init), [update](#update), [view](#view)
pub fn application(
init init: fn(flags) -> #(model, Effect(message)),
update update: fn(model, message) -> #(model, Effect(message)),
subscriptions subscriptions: fn(model) -> Subscription(message),
view view: fn(model) -> Element(message),
) -> App(flags, Model(model, message), Message(message)) {
runtime.application(init, update, subscriptions, view)
}
/// Create a Lustre [component](https://hexdocs.pm/lustre/lustre/component.html)
/// that runs the off_topic subscription runtime. This is the component
/// equivalent of [application](#application).
///
/// If you're using component options, swap your import to `off_topic/component`,
/// offering the same API.
///
/// See also: [application](#application)
pub fn component(
init init: fn(Nil) -> #(model, Effect(message)),
update update: fn(model, message) -> #(model, Effect(message)),
subscriptions subscriptions: fn(model) -> Subscription(message),
view view: fn(model) -> Element(message),
options options: List(component.Option(message)),
) -> App(Nil, Model(model, message), Message(message)) {
runtime.component(init, update, subscriptions, view, options)
}
/// Inline the server component runtime as a `<script>` tag.
///
/// Prefer serving the pre-built file from off_topic's `priv/static` directory
/// when possible. This inline version is useful for development or when you
/// do not control the HTML document.
pub fn script() -> Element(message) {
html.script(
[attribute.type_("module")],
// <<INJECT SCRIPT>>
"var de=e=>e.head,O=e=>e.tail;var ke=5,ct=(1<<ke)-1,at=Symbol(),pt=Symbol();var ge=[\" \",\" \",`\\n`,\"\\v\",\"\\f\",\"\\r\",\"\\x85\",\"\\u2028\",\"\\u2029\"].join(\"\"),Rt=new RegExp(`^[${ge}]*`),Pt=new RegExp(`[${ge}]*$`);var Vt=-(2**31),Wt=2**31-1,Gt=2**32,Ht=Number.MAX_SAFE_INTEGER,Xt=Number.MIN_SAFE_INTEGER;function Se(e,t){if(Array.isArray(e))for(let r=0;r<e.length;r++)t(e[r]);else if(e)for(e;O(e);e=O(e))t(de(e))}function u(e,t){return e instanceof EventTarget?e:e===\"root\"||e===\":scope\"?t:e===\"host\"?t?.host:e===\"document\"?document:e===\"window\"?window:(t||(t=document),t instanceof Element&&t.matches(e)?t:t.querySelector(e))}function $(e,t,r,n){let i=u(e,n);if(!i)throw new Error(\"invalid target\");return i.addEventListener(t,r,{passive:!0}),()=>i.removeEventListener(t,r)}function U(e){let t=()=>document.visibilityState===\"hidden\"?\"hidden\":document.hasFocus()?\"active\":\"passive\",r=s=>{s!==n&&(e(s),n=s)},n=t();n!==\"active\"&&e(n);let i=new AbortController,o={capture:!0,signal:i.signal};return[\"pageshow\",\"focus\",\"blur\",\"visibilitychange\",\"resume\"].forEach(s=>{window.addEventListener(s,()=>r(t()),o)}),window.addEventListener(\"freeze\",()=>{r(\"frozen\")},o),window.addEventListener(\"pagehide\",s=>{r(s.persisted?\"frozen\":\"terminated\")},o),()=>i.abort()}function j(e){let t=()=>-new Date().getTimezoneOffset(),r=t();e(r);let n=setInterval(()=>{let i=t();i!==r&&(r=i,e(i))},6e4);return()=>clearInterval(n)}var et=ze(window,\"online\",\"offline\",()=>navigator.onLine),D=ze(document.documentElement,\"pointerenter\",\"pointerleave\",()=>document.documentElement.matches(\":hover\"));function F(e){let t=r=>{r.preventDefault(),e()};return window.addEventListener(\"beforeunload\",t),()=>window.removeEventListener(\"beforeunload\",t)}function R(e,t){let r=!1,n=null,i=()=>{r&&(r=!1,t(!1)),clearTimeout(n),n=setTimeout(()=>{r=!0,t(!0)},e)},o=()=>{document.hidden||i()},s=[\"pointermove\",\"pointerdown\",\"keydown\",\"scroll\",\"wheel\",\"resize\"];for(let l of s)window.addEventListener(l,i,{passive:!0});return document.addEventListener(\"visibilitychange\",o),i(),()=>{clearTimeout(n);for(let l of s)window.removeEventListener(l,i);document.removeEventListener(\"visibilitychange\",o)}}function P(e){return I(null,window,\"resize\",()=>e(window.innerWidth,window.innerHeight))}function V(e){return I(null,window,\"scroll\",()=>e(window.scrollX,window.scrollY))}function W(e){return I(null,screen.orientation,\"change\",()=>e(screen.orientation.type))}function G(e){return I(null,window,\"languagechange\",()=>e(navigator.language))}function H(e,t,r){let n=[];Se(t,([s,l])=>{n.push([window.matchMedia(s),l])});let i=()=>n.find(([s])=>s.matches)?.[1]??e,o=()=>r(i());o();for(let[s]of n)s.addEventListener(\"change\",o);return()=>{for(let[s]of n)s.removeEventListener(\"change\",o)}}var X=Ee(localStorage),K=Ee(sessionStorage);function Ee(e){return(t,r)=>{let n=e.getItem(t);return r(n!==null,n??\"\"),$(window,\"storage\",o=>{if(o.storageArea===e&&o.key===t){let s=o.newValue;r(s!==null,s??\"\")}})}}function Y(e,t,r){let n=u(e,r);if(!n)return c;let{width:i,height:o}=n.getBoundingClientRect();t(i,o);let s=new ResizeObserver(l=>{let{width:p,height:y}=l[0].contentRect;t(p,y)});return s.observe(n),()=>s.disconnect()}function J(e,t,r){let n=u(e,r);if(!n)return c;let i=n.getBoundingClientRect();t(i.bottom>0&&i.right>0&&i.top<window.innerHeight&&i.left<window.innerWidth);let o=new IntersectionObserver(s=>{t(s[0].isIntersecting)});return o.observe(n),()=>o.disconnect()}function Q(e,t,r){let n=u(e,r);if(!n)return c;let i=()=>t(n.scrollLeft,n.scrollTop);return i(),n.addEventListener(\"scroll\",i,{passive:!0}),()=>n.removeEventListener(\"scroll\",i)}function Z(e){return document.title=e,c}function ee(e,t){let r=document.head.querySelector(`meta[name=\"${CSS.escape(e)}\"]`);return r||(r=document.createElement(\"meta\"),r.setAttribute(\"name\",e),document.head.appendChild(r)),r.setAttribute(\"content\",t),c}function te(e){let t=e.split(\" \");return document.body.classList.add(...t),()=>document.body.classList.remove(...t)}function ne(e,t,r,n){let o=(n instanceof ShadowRoot?n.host:n)?.internals;if(o){let s=e===\"role\"?\"role\":\"aria\"+e.slice(5).replace(/-([a-z])/g,(l,p)=>p.toUpperCase()).replace(/^./,l=>l.toUpperCase());o[s]=t}return c}function re(e,t,r,n){let o=(n instanceof ShadowRoot?n.host:n)?.internals;return o&&(t?o.states.add(e):o.states.delete(e)),c}function ie(e,t,r,n){return(n instanceof ShadowRoot?n.host:n)?.internals?.setFormValue(e?t:void 0),c}function I(e,t,r,n){let i=u(t,e);return n(),$(i,r,n)}function ze(e,t,r,n){return i=>{i(n());let o=()=>i(!0),s=()=>i(!1);return e.addEventListener(t,o),e.addEventListener(r,s),()=>{e.removeEventListener(t,o),e.removeEventListener(r,s)}}}function c(){}function Be(e,t,r,n){window.dispatchEvent(new StorageEvent(\"storage\",{key:t,oldValue:r,newValue:n,storageArea:e,url:window.location.href}))}function oe(e,t,r){let n=r?localStorage.getItem(e):null;localStorage.setItem(e,t),r&&Be(localStorage,e,n,t)}function se(e,t,r){let n=r?sessionStorage.getItem(e):null;sessionStorage.setItem(e,t),r&&Be(sessionStorage,e,n,t)}function le(e,t,r,n,i){u(e,i)?.scrollTo({left:t,top:r,behavior:n})}function fe(e,t,r,n,i){u(e,i)?.scrollIntoView({block:t,inline:r,behavior:n})}function ue(e,t){u(e,t)?.focus()}function ce(){document.activeElement?.blur()}var ae=class extends HTMLElement{#e=null;#t=new Map;#n=!1;connectedCallback(){this.#e=rt(this),this.#e&&(this.#e.addEventListener(\"off-topic:subscribe\",this.#o),this.#e.addEventListener(\"off-topic:unsubscribe\",this.#s),this.#e.addEventListener(\"off-topic:command\",this.#l),this.#e.addEventListener(\"lustre:close\",this.#i),this.#e.addEventListener(\"lustre:open\",this.#r)),this.#n||this.#r()}connectedMoveCallback(){}disconnectedCallback(){this.#e&&(this.#e.removeEventListener(\"off-topic:subscribe\",this.#o),this.#e.removeEventListener(\"off-topic:unsubscribe\",this.#s),this.#e.removeEventListener(\"lustre:close\",this.#i),this.#e.addEventListener(\"lustre:open\",this.#r),this.#e=null),queueMicrotask(()=>{this.isConnected||this.#i()})}#o=t=>{let{detail:{tick:r,path:n,name:i,params:o,include:s,throttle:l=0,delay:p=0}}=t,y=`${r}:${n.join(\":\")}`;if(this.#t.has(y))return;let pe=window.OffTopic.subscriptions[i];if(!pe){console.warn(`off-topic: subscription '${i}' requested, but not registered`);return}let a={tick:r,path:n,params:o,cleanup:void 0,lastFired:0,lastThrottledData:void 0,delayTimeout:void 0},C=d=>{let _=Array.isArray(d)?d.map(Ie=>Ae(Ie,s)):Ae(d,s),S=new CustomEvent(\"off-topic:dispatch\",{detail:{tick:r,path:n,message:_}});this.dispatchEvent(S)},ve=(...d)=>{let _=d.length===1?d[0]:d;if(l>0){let S=Date.now();S>=a.lastFired+l&&(a.lastFired=S,a.lastThrottledData=_,C(_))}p>0&&(clearTimeout(a.delayTimeout),a.delayTimeout=setTimeout(()=>{_!==a.lastThrottledData&&C(_)},p)),!l&&!p&&C(_)};a.cleanup=pe(...o,ve,this.#e),this.#t.set(y,a)};#s=t=>{let{detail:{tick:r,path:n}}=t,i=`${r}:${n.join(\":\")}`,o=this.#t.get(i);o&&(this.#t.delete(i),clearTimeout(o.delayTimeout),o.cleanup?.())};#l=t=>{let{detail:{name:r,params:n}}=t;window.OffTopic.commands[r]?.(...n,this.#e)};#r=()=>{this.dispatchEvent(new CustomEvent(\"off-topic:mount\")),this.#n=!0};#i=()=>{for(let t of this.#t.values())clearTimeout(t.delayTimeout),t.cleanup?.();this.#t.clear(),this.#n=!1}};function rt(e){for(;e!=null;){if(e instanceof ShadowRoot&&e.host.tagName.toLowerCase()===\"lustre-server-component\")return e.host;e=e.parentNode}return console.error(`<ot-client-runtime> is not mounted inside a Lustre server component and will not work!\\nIf you are using lustre.start, you can use off_topic's effects directly and can remove this component.`),null}function Ae(e,t){if(typeof e!=\"object\")return e;let r={};for(let n of t){let i=n.split(\".\");for(let o=0,s=e,l=r;o<i.length;o++)o===i.length-1?l[i[o]]=s[i[o]]:(l=l[i[o]]??={},s=s[i[o]])}return r}window.OffTopic??={};window.OffTopic.subscriptions??={};window.OffTopic.commands??={};customElements.define(\"ot-client-runtime\",ae);Object.assign(window.OffTopic.subscriptions,{\"off-topic/event\":$,\"off-topic/page-state\":U,\"off-topic/prevent-unload\":F,\"off-topic/media-query\":H,\"off-topic/window-hover\":D,\"off-topic/here\":j,\"off-topic/window-size\":P,\"off-topic/window-scroll\":V,\"off-topic/screen-orientation\":W,\"off-topic/language\":G,\"off-topic/local-storage\":X,\"off-topic/session-storage\":K,\"off-topic/title\":Z,\"off-topic/meta\":ee,\"off-topic/body-class\":te,\"off-topic/aria\":ne,\"off-topic/pseudo-state\":re,\"off-topic/form-value\":ie,\"off-topic/element-size\":Y,\"off-topic/element-scroll\":Q,\"off-topic/element-visible\":J,\"off-topic/on-idle\":R});Object.assign(window.OffTopic.commands,{\"off-topic/set-local-storage\":oe,\"off-topic/set-session-storage\":se,\"off-topic/scroll-to\":le,\"off-topic/scroll-into-view\":fe,\"off-topic/focus\":ue,\"off-topic/blur\":ce});",
)
}
// Run the application init function and start subscriptions.
///
/// The low-level counterpart to [application](#application), for use when you
/// need to compose off_topic into an existing `lustre.application` manually.
///
/// See also: [update](#update), [view](#view), [application](#application)
pub fn init(
flags flags: flags,
init init: fn(flags) -> #(model, Effect(message)),
) -> #(Model(model, message), Effect(Message(message))) {
runtime.init(flags, init)
}
/// Handle a message in the subscription runtime.
///
/// The low-level counterpart to [application](#application), for use when you
/// need to compose off_topic into an existing `lustre.application` manually.
///
/// See also: [init](#init), [view](#view), [application](#application)
pub fn update(
model: Model(model, message),
message: Message(message),
subscriptions: fn(model) -> Subscription(message),
update: fn(model, message) -> #(model, Effect(message)),
) -> #(Model(model, message), Effect(Message(message))) {
runtime.update(model, message, subscriptions, update)
}
/// Render the application view through the subscription runtime.
///
/// The low-level counterpart to [application](#application), for use when you
/// need to compose off_topic into an existing `lustre.application` manually.
/// On the server target, this also injects the `ot-client-runtime` web
/// component into the rendered output.
///
/// See also: [init](#init), [update](#update), [application](#application)
pub fn view(
model: Model(model, message),
view: fn(model) -> Element(message),
) -> Element(Message(message)) {
runtime.view(model, view)
}
// -- MAKING SUBSCRIPTIONS ----------------------------------------------------
/// A subscription that never fires.
///
/// See also: [batch](#batch)
pub fn none() -> Subscription(message) {
runtime.none()
}
/// Build a subscription from an arbitrary setup callback.
///
/// The callback receives a `dispatch` function and must return a cleanup
/// function that will be called when the subscription is torn down. off_topic
/// restarts the subscription whenever any value in `dependencies` changes.
///
/// For the exact rules on when and how subscriptions and their cleanup
/// functions fire, see [the guide](#TODO).
///
/// See also: [element](#element), [resource](#resource)
pub fn from(
watching dependencies: List(Dependency),
run callback: fn(fn(message) -> Nil) -> fn() -> Nil,
) -> Subscription(message) {
runtime.from(dependencies, callback)
}
/// Build a subscription with access to the Lustre root element. <small>JS</small>
///
/// Like [from](#from), but the setup callback also receives the DOM element
/// that Lustre is mounted on, allowing you to attach listeners scoped to that
/// element rather than the window or document.
///
/// See also: [from](#from), [resource](#resource)
pub fn element(
watching dependencies: List(Dependency),
run callback: fn(fn(message) -> Nil, Dynamic) -> fn() -> Nil,
) -> Subscription(message) {
runtime.element(dependencies, callback)
}
/// Combine a list of subscriptions into one.
///
/// See also: [none](#none)
pub fn batch(list: List(Subscription(message))) -> Subscription(message) {
runtime.batch(list)
}
/// Wrap a value as a subscription dependency.
///
/// off_topic restarts a subscription whenever any of its dependency values
/// change. When making your own subscriptions, keep in mind that you should
/// typically **not** pass the message callback as a dependency.
///
/// See also: [from](#from), [watching](#watching)
pub fn dep(value: any) -> Dependency {
runtime.dep(value)
}
/// Transform the messages a subscription dispatches.
pub fn map(
subscription: Subscription(a),
with tagger: fn(a) -> b,
) -> Subscription(b) {
runtime.map(subscription, tagger)
}
/// Run a side effect whenever dependencies change.
///
/// The callback fires once at subscription setup and again whenever any
/// dependency changes.
///
/// See also: [from](#from), [resource](#resource)
pub fn watch(
watching dependencies: List(Dependency),
run callback: fn() -> Nil,
) -> Subscription(message) {
runtime.watch(dependencies, callback)
}
/// Build a subscription that runs on the server component client.
///
/// See also: [including](#including)
pub fn remote(
name name: String,
with params: List(Json),
run decoder: Decoder(message),
) -> Subscription(message) {
runtime.remote(name, params, decoder)
}
/// Dispatch a named browser command through the off_topic client runtime. <small>SC</small>
///
/// The server-component counterpart to `effect.from`: dispatches an
/// `off-topic:command` event that the `ot-client-runtime` web component
/// routes to the registered `window.OffTopic.commands[name]` handler on the
/// client.
///
/// For the built-in commands (`set_local_storage`, `scroll_to`, etc.) both
/// paths are handled automatically — use those directly. Reserve `command`
/// for custom handlers you register yourself.
///
/// See also: [remote](#remote)
pub fn command(name: String, params: List(Json)) -> Effect(message) {
runtime.command(name, params)
}
/// Specify which fields are forwarded from a remote subscription's events.
///
/// Remote subscriptions have to explicitely list all fields they want to
/// forward to the server. Has no effect on non-`Remote` subscriptions.
///
/// See also: [remote](#remote)
pub fn including(
subscription: Subscription(message),
paths: List(String),
) -> Subscription(message) {
runtime.including(subscription, paths)
}
/// Limit how often a subscription dispatches messages.
///
/// When a subscription fires more frequently than `duration`, intermediate
/// messages are dropped. The first message always passes through immediately.
///
/// See also: [delay](#delay)
pub fn throttle(
subscription: Subscription(message),
wait duration: Duration,
) -> Subscription(message) {
runtime.throttle(subscription, duration.to_milliseconds(duration))
}
/// Hold a subscription's message until the subscription has been quiet for `duration`.
///
/// When combined with [throttle](#throttle), the throttle pass-through fires
/// immediately and a delayed message is also queued for the final event in
/// each burst.
///
/// See also: [throttle](#throttle)
pub fn delay(
subscription: Subscription(message),
wait duration: Duration,
) -> Subscription(message) {
runtime.delay(subscription, duration.to_milliseconds(duration))
}
/// Add extra dependencies to a subscription.
///
/// off_topic restarts the subscription whenever any dependency changes. Useful
/// when a subscription's behaviour depends on model values not captured in its
/// own parameters.
///
/// See also: [dep](#dep), [from](#from)
pub fn watching(
subscription: Subscription(message),
watching dependencies: List(Dependency),
) -> Subscription(message) {
runtime.watching(subscription, dependencies)
}
// -- KEYBOARD ---------------------------------------------------------------
/// A keyboard event payload.
pub type Key {
Key(key: String, code: String, ctrl: Bool, alt: Bool, meta: Bool, shift: Bool)
}
// @Volatile: Keep in sync with decoder
const key_include = ["key", "code", "ctrlKey", "altKey", "metaKey", "shiftKey"]
fn key_decoder() -> Decoder(Key) {
use key <- decode.field("key", decode.string)
use code <- decode.field("code", decode.string)
use ctrl <- decode.field("ctrlKey", decode.bool)
use alt <- decode.field("altKey", decode.bool)
use meta <- decode.field("metaKey", decode.bool)
use shift <- decode.field("shiftKey", decode.bool)
decode.success(Key(key:, code:, ctrl:, alt:, meta:, shift:))
}
/// Subscribe to keypress events on the document.
///
/// See also: [on_key_down](#on_key_down), [on_key_up](#on_key_up)
pub fn on_key_press(send message: fn(Key) -> message) -> Subscription(message) {
on(Document, "keypress", decode.map(key_decoder(), message))
|> including(key_include)
}
/// Subscribe to keydown events on the document.
///
/// See also: [on_key_press](#on_key_press), [on_key_up](#on_key_up)
pub fn on_key_down(send message: fn(Key) -> message) -> Subscription(message) {
on(Document, "keydown", decode.map(key_decoder(), message))
|> including(key_include)
}
/// Subscribe to keyup events on the document.
///
/// See also: [on_key_press](#on_key_press), [on_key_down](#on_key_down)
pub fn on_key_up(send message: fn(Key) -> message) -> Subscription(message) {
on(Document, "keyup", decode.map(key_decoder(), message))
|> including(key_include)
}
// -- POINTER -----------------------------------------------------------------
/// The pointing device type reported by a pointer event.
pub type PointerType {
/// Standard mouse or trackpad.
Mouse
/// Stylus or digital pen.
Pen
/// Touchscreen finger contact.
Touch
}
/// A pointer event payload.
pub type Pointer {
Pointer(
x: Float,
y: Float,
button: Int,
pointer_type: PointerType,
pointer_id: Int,
is_primary: Bool,
pressure: Float,
tilt_x: Int,
tilt_y: Int,
ctrl: Bool,
alt: Bool,
meta: Bool,
shift: Bool,
)
}
// @Volatile: Keep in sync with decoder
const pointer_include = [
"clientX",
"clientY",
"button",
"pointerType",
"pointerId",
"isPrimary",
"pressure",
"tiltX",
"tiltY",
"ctrlKey",
"altKey",
"metaKey",
"shiftKey",
]
fn pointer_type_decoder() -> Decoder(PointerType) {
use pointer_type <- decode.then(decode.string)
case pointer_type {
"pen" -> decode.success(Pen)
"touch" -> decode.success(Touch)
_ -> decode.success(Mouse)
}
}
fn pointer_decoder() -> Decoder(Pointer) {
use x <- decode.field("clientX", float())
use y <- decode.field("clientY", float())
use button <- decode.field("button", int())
use pointer_type <- decode.optional_field(
"pointerType",
Mouse,
pointer_type_decoder(),
)
use pointer_id <- decode.optional_field("pointerId", -1, int())
use is_primary <- decode.optional_field("isPrimary", True, decode.bool)
use pressure <- decode.optional_field("pressure", 0.0, float())
use tilt_x <- decode.optional_field("tiltX", 0, int())
use tilt_y <- decode.optional_field("tiltY", 0, int())
use ctrl <- decode.field("ctrlKey", decode.bool)
use alt <- decode.field("altKey", decode.bool)
use meta <- decode.field("metaKey", decode.bool)
use shift <- decode.field("shiftKey", decode.bool)
decode.success(Pointer(
x:,
y:,
button:,
pointer_type:,
pointer_id:,
is_primary:,
pressure:,
tilt_x:,
tilt_y:,
ctrl:,
alt:,
meta:,
shift:,
))
}
fn int() {
decode.one_of(decode.int, [decode.map(decode.float, float.round)])
}
fn float() {
decode.one_of(decode.float, [decode.map(decode.int, int.to_float)])
}
/// Subscribe to click events on the document.
///
/// See also: [on_pointer_down](#on_pointer_down), [on_pointer_up](#on_pointer_up)
pub fn on_click(send message: fn(Pointer) -> message) -> Subscription(message) {
on(Document, "click", decode.map(pointer_decoder(), message))
|> including(pointer_include)
}
/// Subscribe to pointer move events on the document.
///
/// See also: [on_pointer_down](#on_pointer_down), [on_pointer_up](#on_pointer_up)
pub fn on_pointer_move(
send message: fn(Pointer) -> message,
) -> Subscription(message) {
on(Document, "pointermove", decode.map(pointer_decoder(), message))
|> including(pointer_include)
}
/// Subscribe to pointer down events on the document.
///
/// See also: [on_pointer_up](#on_pointer_up), [on_click](#on_click)
pub fn on_pointer_down(
send message: fn(Pointer) -> message,
) -> Subscription(message) {
on(Document, "pointerdown", decode.map(pointer_decoder(), message))
|> including(pointer_include)
}
/// Subscribe to pointer up events on the document.
///
/// See also: [on_pointer_down](#on_pointer_down), [on_click](#on_click)
pub fn on_pointer_up(
send message: fn(Pointer) -> message,
) -> Subscription(message) {
on(Document, "pointerup", decode.map(pointer_decoder(), message))
|> including(pointer_include)
}
/// Subscribe to pointer cancel events on the document.
///
/// Fires when the browser interrupts a pointer sequence — for example when a
/// touch is cancelled by an incoming phone call or the pointer leaves the
/// browser window mid-drag.
///
/// See also: [on_pointer_down](#on_pointer_down), [on_pointer_up](#on_pointer_up)
pub fn on_pointer_cancel(
send message: fn(Pointer) -> message,
) -> Subscription(message) {
on(Document, "pointercancel", decode.map(pointer_decoder(), message))
|> including(pointer_include)
}
/// Scroll-wheel delta units reported by the browser.
///
/// The browser decides which unit it uses; the most common is `Pixel`.
/// Never assume the delta is in `Pixel` mode without checking `delta_mode`.
pub type WheelDeltaMode {
Pixel
Line
Page
}
/// A wheel event payload.
pub type Wheel {
Wheel(
delta_x: Float,
delta_y: Float,
delta_z: Float,
delta_mode: WheelDeltaMode,
ctrl: Bool,
alt: Bool,
meta: Bool,
shift: Bool,
)
}
// @Volatile: Keep in sync with decoder
const wheel_include = [
"deltaX",
"deltaY",
"deltaZ",
"deltaMode",
"ctrlKey",
"altKey",
"metaKey",
"shiftKey",
]
fn wheel_delta_mode_decoder() -> Decoder(WheelDeltaMode) {
use n <- decode.then(decode.int)
case n {
1 -> decode.success(Line)
2 -> decode.success(Page)
_ -> decode.success(Pixel)
}
}
fn wheel_decoder() -> Decoder(Wheel) {
use delta_x <- decode.field("deltaX", float())
use delta_y <- decode.field("deltaY", float())
use delta_z <- decode.field("deltaZ", float())
use delta_mode <- decode.field("deltaMode", wheel_delta_mode_decoder())
use ctrl <- decode.field("ctrlKey", decode.bool)
use alt <- decode.field("altKey", decode.bool)
use meta <- decode.field("metaKey", decode.bool)
use shift <- decode.field("shiftKey", decode.bool)
decode.success(Wheel(
delta_x:,
delta_y:,
delta_z:,
delta_mode:,
ctrl:,
alt:,
meta:,
shift:,
))
}
/// Subscribe to wheel events on the document.
///
/// See also: [window_scroll](#window_scroll)
pub fn on_wheel(send message: fn(Wheel) -> message) -> Subscription(message) {
on(Document, "wheel", decode.map(wheel_decoder(), message))
|> including(wheel_include)
}
// -- BROWSER -----------------------------------------------------------------
/// Dispatch the current window size, then again whenever it changes.
///
/// See also: [breakpoints](#breakpoints), [tailwind_breakpoints](#tailwind_breakpoints)
pub fn window_size(
send message: fn(Float, Float) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch <- from(watching: [dep("off-topic/window-size")])
subscriptions.window_size(fn(w, h) { dispatch(message(w, h)) })
}
False -> remote("off-topic/window-size", [], float_pair_decoder(message))
}
}
/// The screen orientation reported by the browser.
pub type ScreenOrientation {
/// Portrait with the top of the device facing up.
PortraitPrimary
/// Portrait with the top of the device facing down (upside-down).
PortraitSecondary
/// Landscape with the top of the device facing right.
LandscapePrimary
/// Landscape with the top of the device facing left.
LandscapeSecondary
}
/// Dispatch the current screen orientation, then again whenever it changes.
pub fn screen_orientation(
send message: fn(ScreenOrientation) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch <- from(watching: [dep("off-topic/screen-orientation")])
subscriptions.screen_orientation({
string_callback(parse_screen_orientation, message, dispatch)
})
}
False ->
remote(name: "off-topic/screen-orientation", with: [], run: {
string_decoder(parse_screen_orientation, LandscapeSecondary, message)
})
}
}
fn parse_screen_orientation(s: String) -> Result(ScreenOrientation, Nil) {
case s {
"portrait-primary" -> Ok(PortraitPrimary)
"portrait-secondary" -> Ok(PortraitSecondary)
"landscape-primary" -> Ok(LandscapePrimary)
"landscape-secondary" -> Ok(LandscapeSecondary)
_ -> Error(Nil)
}
}
fn string_callback(
parse: fn(String) -> Result(a, Nil),
to_msg: fn(a) -> message,
dispatch: fn(message) -> Nil,
) -> fn(String) -> Nil {
fn(s) {
case parse(s) {
Ok(v) -> dispatch(to_msg(v))
Error(_) -> Nil
}
}
}
fn string_decoder(
parse: fn(String) -> Result(a, Nil),
fallback: a,
to_msg: fn(a) -> message,
) -> Decoder(message) {
use s <- decode.then(decode.string)
case parse(s) {
Ok(v) -> decode.success(to_msg(v))
Error(_) -> decode.failure(to_msg(fallback), "String")
}
}
/// Dispatch the current scroll position, then again whenever it changes.
pub fn window_scroll(
send message: fn(Float, Float) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch <- from(watching: [dep("off-topic/window-scroll")])
subscriptions.window_scroll(fn(x, y) { dispatch(message(x, y)) })
}
False -> remote("off-topic/window-scroll", [], float_pair_decoder(message))
}
}
fn float_pair_decoder(to_msg) {
use x <- decode.field("0", float())
use y <- decode.field("1", float())
decode.success(to_msg(x, y))
}
/// The scroll animation style.
pub type ScrollBehaviour {
Smooth
Instant
Auto
}
/// Scroll the window to a specific position.
///
/// `x` and `y` are pixel offsets from the top-left corner of the document.
///
/// See also: [window_scroll](#window_scroll), [scroll_into_view](#scroll_into_view)
pub fn scroll_to(
x x: Float,
y y: Float,
behaviour behaviour: ScrollBehaviour,
) -> Effect(message) {
let b = scroll_behaviour_string(behaviour)
case lustre.is_browser() {
True ->
effect.before_paint(fn(_, root) {
commands.scroll_to("window", x, y, b, root)
})
False ->
command("off-topic/scroll-to", [
json.string("window"),
json.float(x),
json.float(y),
json.string(b),
])
}
}
fn scroll_behaviour_string(behaviour: ScrollBehaviour) -> String {
case behaviour {
Smooth -> "smooth"
Instant -> "instant"
Auto -> "auto"
}
}
/// The current browser page lifecycle state.
///
/// Transitions follow the [Page Lifecycle API](https://developer.chrome.com/docs/web-platform/page-lifecycle-api).
/// `Frozen` pages may resume or be silently discarded by the browser;
/// `Terminated` is always final. The `Discarded` lifecycle state is absent
/// because the browser unloads discarded pages without firing any events.
pub type PageState {
/// Visible and has input focus.
Active
/// Visible but does not have input focus.
Passive
/// Not visible, and not frozen, discarded, or terminated.
Hidden
/// Browser has suspended execution to conserve resources; may resume or be discarded silently.
Frozen
/// Page has started unloading and will not resume.
Terminated
}
/// Dispatch the current page lifecycle state, then again whenever it changes.
///
/// No initial dispatch fires when the page starts in the `Active` state, which
/// is the common case on load. If the page is already `Passive` or `Hidden` at
/// subscription setup, the current state dispatches immediately.
pub fn page_state(
send handle_page_state: fn(PageState) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch <- from(watching: [dep("off-topic/page-state")])
subscriptions.on_page_state({
string_callback(parse_page_state, handle_page_state, dispatch)
})
}
False ->
remote(
name: "off-topic/page-state",
with: [],
run: string_decoder(parse_page_state, Passive, handle_page_state),
)
}
}
fn parse_page_state(s: String) -> Result(PageState, Nil) {
case s {
"active" -> Ok(Active)
"passive" -> Ok(Passive)
"hidden" -> Ok(Hidden)
"frozen" -> Ok(Frozen)
"terminated" -> Ok(Terminated)
_ -> Error(Nil)
}
}
/// Dispatch the current window hover state, then again whenever it changes.
///
/// Only reliable when the window has focus; browsers do not consistently
/// deliver pointer events to unfocused windows.
pub fn window_hover(
send message: fn(Bool) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch <- from(watching: [dep("off-topic/window-hover")])
subscriptions.window_hover(fn(b) { dispatch(message(b)) })
}
False ->
remote("off-topic/window-hover", [], decode.map(decode.bool, message))
}
}
/// Update the document `<title>`.
///
/// See also: [meta](#meta)
pub fn title(value value: String) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use _ <- from(watching: [dep(value)])
subscriptions.title(value)
}
False -> remote("off-topic/title", [json.string(value)], no_decoder())
}
}
/// Update or insert a `<meta name="…" content="…">` tag.
///
/// See also: [title](#title)
pub fn meta(
name name: String,
content content: String,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use _ <- from(watching: [dep(name), dep(content)])
subscriptions.meta(name, content)
}
False ->
remote(
"off-topic/meta",
[json.string(name), json.string(content)],
no_decoder(),
)
}
}
/// Add a CSS class to the document body.
///
/// The class is removed when the subscription stops or the class name changes.
///
/// See also: [title](#title)
pub fn body_class(class_name: String) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use _ <- from(watching: [dep(class_name)])
subscriptions.body_class(class_name)
}
False -> {
remote("off-topic/body-class", [json.string(class_name)], no_decoder())
}
}
}
/// Block the user from immediately leaving the page when enabled, showing a
/// simple popup instead.
///
/// Useful for when your page has unsaved changes that you don't want users
/// to accidentily lose.
pub fn prevent_unload(
prevent enabled: Bool,
send message: message,
) -> Subscription(message) {
use <- bool.guard(when: !enabled, return: none())
case lustre.is_browser() {
True -> {
use dispatch <- from(watching: [dep("off-topic/prevent-unload")])
use <- subscriptions.prevent_unload
dispatch(message)
}
False -> remote("off-topic/prevent-unload", [], decode.success(message))
}
}
fn no_decoder() -> Decoder(message) {
use _ <- decode.then(decode.dynamic)
panic as "this decoder is unreachable"
}
// -- DOCUMENT OBJECT MODEL ---------------------------------------------------
/// Dispatch the size of an element, then again whenever it changes.
///
/// Pass [root](#root) to observe the component mount point, or any CSS
/// selector for a descendant.
///
/// See also: [window_size](#window_size), [tailwind_breakpoints](#tailwind_breakpoints),
/// [element_scroll](#element_scroll), [element_visible](#element_visible)
pub fn element_size(
selector selector: String,
send message: fn(Float, Float) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch, el <- element(watching: [dep(selector)])
let handler = fn(w, h) { dispatch(message(w, h)) }
subscriptions.element_size(selector, handler, el)
}
False ->
remote(
name: "off-topic/element-size",
with: [json.string(selector)],
run: float_pair_decoder(message),
)
}
}
/// Dispatch the scroll position of an element, then again whenever it changes.
///
/// Pass [root](#root) to observe the component mount point, or any CSS
/// selector for a descendant.
///
/// See also: [window_scroll](#window_scroll), [element_size](#element_size),
/// [element_visible](#element_visible)
pub fn element_scroll(
selector selector: String,
send message: fn(Float, Float) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch, el <- element(watching: [dep(selector)])
let handler = fn(x, y) { dispatch(message(x, y)) }
subscriptions.element_scroll(selector, handler, el)
}
False ->
remote(
name: "off-topic/element-scroll",
with: [json.string(selector)],
run: float_pair_decoder(message),
)
}
}
/// Scroll an element to a specific position.
///
/// `x` and `y` are pixel offsets within the scrollable element. Pass [root](#root)
/// to target the component mount point, or any CSS selector for a descendant.
///
/// See also: [element_scroll](#element_scroll), [scroll_to](#scroll_to)
pub fn element_scroll_to(
selector selector: String,
x x: Float,
y y: Float,
behaviour behaviour: ScrollBehaviour,
) -> Effect(message) {
let b = scroll_behaviour_string(behaviour)
case lustre.is_browser() {
True ->
effect.before_paint(fn(_, root) {
commands.scroll_to(selector, x, y, b, root)
})
False ->
command("off-topic/scroll-to", [
json.string(selector),
json.float(x),
json.float(y),
json.string(b),
])
}
}
/// The block or inline alignment for `scroll_into_view`.
pub type ScrollAlignment {
Start
Center
End
Nearest
}
/// Scroll the first element matching `selector` into view.
///
/// See also: [scroll_to](#scroll_to)
pub fn scroll_into_view(
selector selector: String,
block block: ScrollAlignment,
inline inline: ScrollAlignment,
behaviour behaviour: ScrollBehaviour,
) -> Effect(message) {
let block = scroll_alignment_string(block)
let inline = scroll_alignment_string(inline)
let behaviour = scroll_behaviour_string(behaviour)
case lustre.is_browser() {
True ->
effect.before_paint(fn(_, root) {
commands.scroll_into_view(selector, block, inline, behaviour, root)
})
False ->
command("off-topic/scroll-into-view", [
json.string(selector),
json.string(block),
json.string(inline),
json.string(behaviour),
])
}
}
fn scroll_alignment_string(alignment: ScrollAlignment) -> String {
case alignment {
Start -> "start"
Center -> "center"
End -> "end"
Nearest -> "nearest"
}
}
/// Dispatch whether an element intersects the viewport, then again whenever it changes.
///
/// Useful for infinite scroll sentinels and lazy load / pause-on-hide patterns.
///
/// See also: [element_size](#element_size), [element_scroll](#element_scroll)
pub fn element_visible(
selector selector: String,
send message: fn(Bool) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch, el <- element(watching: [dep(selector)])
let handler = fn(visible) { dispatch(message(visible)) }
subscriptions.element_visible(selector, handler, el)
}
False ->
remote(
name: "off-topic/element-visible",
with: [json.string(selector)],
run: decode.map(decode.bool, message),
)
}
}
/// Focus the first element matching `selector`.
///
/// See also: [blur](#blur)
pub fn focus(selector selector: String) -> Effect(message) {
case lustre.is_browser() {
True -> effect.before_paint(fn(_, root) { commands.focus(selector, root) })
False -> command("off-topic/focus", [json.string(selector)])
}
}
/// Blur (unfocus) the currently focused element.
///
/// See also: [focus](#focus)
pub fn blur() -> Effect(message) {
case lustre.is_browser() {
True -> effect.from(fn(_) { commands.blur() })
False -> command("off-topic/blur", [])
}
}
/// The DOM target to attach an event listener to.
///
/// See also: [on](#on), [target](#target)
pub type Target {
/// The shadow host of the Lustre component (custom element).
Host
/// The element Lustre is mounted on.
Root
/// The browser window.
Window
/// The document.
Document
}
/// Subscribe to a DOM event on a target element.
pub fn on(
on target: Target,
event name: String,
run decoder: Decoder(message),
) -> Subscription(message) {
let target_str = case target {
Host -> "host"
Root -> "root"
Window -> "window"
Document -> "document"
}
case lustre.is_browser() {
True -> {
use dispatch, el <- element(watching: [dep(target_str), dep(name)])
let handler = fn(event) {
case decode.run(event, decoder) {
Ok(msg) -> dispatch(msg)
Error(_) -> Nil
}
}
subscriptions.event(target_str, name, handler, el)
}
False ->
remote(
name: "off-topic/event",
with: [json.string(target_str), json.string(name)],
run: decoder,
)
}
}
// -- COMPONENT ---------------------------------------------------------------
/// Sync a single ARIA property on the component host's `ElementInternals`.
///
/// `name` is attribute-style (`"role"`, `"aria-expanded"`, `"aria-label"`, …);
/// off_topic converts it to the camelCase `ElementInternals` property and sets
/// it on the host's `ElementInternals`. Does nothing outside a component context.
///
/// See also: [pseudo_state](#pseudo_state), [form_value](#form_value)
pub fn aria(name name: String, value value: String) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch, root <- element(watching: [dep(name), dep(value)])
subscriptions.aria(name, value, dispatch, root)
}
False ->
remote(
"off-topic/aria",
[json.string(name), json.string(value)],
no_decoder(),
)
}
}
/// Sync a single CSS custom state on the component host. Does nothing outside a component context.
///
/// See also: [aria](#aria), [form_value](#form_value)
pub fn pseudo_state(
name name: String,
checked checked: Bool,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch, root <- element(watching: [dep(name), dep(checked)])
subscriptions.pseudo_state(name, checked, dispatch, root)
}
False ->
remote(
"off-topic/pseudo-state",
[json.string(name), json.bool(checked)],
no_decoder(),
)
}
}
/// Sync the form-associated element's submitted value.
///
/// The component has to have the `form_associated` option set.
/// Does nothing outside a component context.
///
/// See also: [aria](#aria), [pseudo_state](#pseudo_state)
pub fn form_value(value value: Option(String)) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch, root <- element(watching: [dep(value)])
subscriptions.form_value(
option.is_some(value),
option.unwrap(value, ""),
dispatch,
root,
)
}
False ->
remote(
"off-topic/form-value",
[
json.bool(option.is_some(value)),
json.string(option.unwrap(value, "")),
],
no_decoder(),
)
}
}
// -- PREFERENCES -----------------------------------------------------------00
/// Dispatch the current navigator language, then again whenever it changes.
///
/// Dispatches a BCP 47 language tag such as `"en-US"`.
pub fn language(send message: fn(String) -> message) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch <- from(watching: [dep("off-topic/language")])
subscriptions.language(fn(s) { dispatch(message(s)) })
}
False ->
remote("off-topic/language", [], decode.map(decode.string, message))
}
}
/// The user's preferred color scheme.
pub type ColourScheme {
Dark
Light
}
/// Dispatch the current color scheme preference, then again whenever it changes.
///
/// See also: [contrast](#contrast), [reduced_motion](#reduced_motion)
pub fn colour_scheme(
send message: fn(ColourScheme) -> message,
) -> Subscription(message) {
use s <- media_query(dep("off-topic/color-scheme"), "light", [
#("(prefers-color-scheme: dark)", "dark"),
])
case s {
"dark" -> message(Dark)
_ -> message(Light)
}
}
/// The user's preferred contrast level.
pub type ContrastPreference {
MoreContrast
LessContrast
ForcedContrast
NoContrastPreference
}
/// Dispatch the current contrast preference, then again whenever it changes.
///
/// See also: [colour_scheme](#colour_scheme), [reduced_motion](#reduced_motion)
pub fn contrast(
send message: fn(ContrastPreference) -> message,
) -> Subscription(message) {
use s <- media_query(dep("off-topic/contrast"), "none", [
#("(prefers-contrast: more)", "more"),
#("(prefers-contrast: less)", "less"),
#("(prefers-contrast: forced)", "forced"),
])
case s {
"more" -> message(MoreContrast)
"less" -> message(LessContrast)
"forced" -> message(ForcedContrast)
_ -> message(NoContrastPreference)
}
}
/// The user's motion sensitivity preference.
pub type MotionPreference {
ReduceMotion
NoMotionPreference
}
/// Dispatch the current motion preference, then again whenever it changes.
///
/// See also: [colour_scheme](#colour_scheme), [contrast](#contrast)
pub fn reduced_motion(
send message: fn(MotionPreference) -> message,
) -> Subscription(message) {
use s <- media_query(dep("off-topic/reduced-motion"), "no-preference", [
#("(prefers-reduced-motion: reduce)", "reduce"),
])
case s {
"reduce" -> message(ReduceMotion)
_ -> message(NoMotionPreference)
}
}
/// Dispatch the current active breakpoint label, then again whenever it changes.
///
/// Pairs are matched largest-first, returning the label whose `min_width` the
/// window satisfies, or `fallback` when none match.
///
/// See also: [window_size](#window_size), [tailwind_breakpoints](#tailwind_breakpoints)
pub fn breakpoints(
fallback fallback: String,
breakpoints pairs: List(#(Int, String)),
send message: fn(String) -> message,
) -> Subscription(message) {
let query_pairs =
pairs
|> list.sort(fn(a, b) { int.compare(b.0, a.0) })
|> list.map(fn(pair) {
#("(min-width: " <> int.to_string(pair.0) <> "px)", pair.1)
})
media_query(dep(query_pairs), fallback, query_pairs, message)
}
/// A Tailwind CSS responsive breakpoint.
pub type TailwindBreakpoint {
Xs
Sm
Md
Lg
Xl
Xxl
}
/// Dispatch the current Tailwind CSS breakpoint, then again whenever it changes.
///
/// See also: [breakpoints](#breakpoints), [window_size](#window_size)
pub fn tailwind_breakpoints(
send message: fn(TailwindBreakpoint) -> message,
) -> Subscription(message) {
use breakpoint <- breakpoints("xs", [
#(640, "sm"),
#(768, "md"),
#(1024, "lg"),
#(1280, "xl"),
#(1536, "2xl"),
])
case breakpoint {
"sm" -> message(Sm)
"md" -> message(Md)
"lg" -> message(Lg)
"xl" -> message(Xl)
"2xl" -> message(Xxl)
_ -> message(Xs)
}
}
fn media_query(
name: Dependency,
fallback: String,
pairs: List(#(String, String)),
convert: fn(String) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch <- from(watching: [name])
subscriptions.media_query(fallback, pairs, fn(s) { dispatch(convert(s)) })
}
False ->
remote(
name: "off-topic/media-query",
with: [
json.string(fallback),
json.array(pairs, fn(pair) {
json.preprocessed_array([json.string(pair.0), json.string(pair.1)])
}),
],
run: decode.map(decode.string, convert),
)
}
}
/// Dispatch the current value of a `localStorage` key, then again whenever it changes.
///
/// Dispatches `None` when the key is absent and `Some(value)` otherwise. Only
/// detects changes made in other tabs or windows — updates made in the current
/// page are not forwarded by the browser's `storage` event unless
/// [`set_local_storage`](#set_local_storage) is called with `notify: True`.
///
/// See also: [session_storage](#session_storage), [set_local_storage](#set_local_storage)
pub fn local_storage(
key key: String,
send message: fn(Option(String)) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch <- from([dep("off-topic/local-storage"), dep(key)])
subscriptions.local_storage(key, {
bool_string_callback(storage_value, message, dispatch)
})
}
False ->
remote(
name: "off-topic/local-storage",
with: [json.string(key)],
run: bool_string_decoder(storage_value, message),
)
}
}
/// Dispatch the current value of a `sessionStorage` key, then again whenever it changes.
///
/// Dispatches `None` when the key is absent and `Some(value)` otherwise. Only
/// detects changes made in other tabs or windows — updates made in the current
/// page are not forwarded by the browser's `storage` event unless
/// [`set_session_storage`](#set_session_storage) is called with `notify: True`.
///
/// See also: [local_storage](#local_storage)
pub fn session_storage(
key key: String,
send message: fn(Option(String)) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch <- from([dep("off-topic/session-storage"), dep(key)])
subscriptions.session_storage(key, {
bool_string_callback(storage_value, message, dispatch)
})
}
False ->
remote(
name: "off-topic/session-storage",
with: [json.string(key)],
run: bool_string_decoder(storage_value, message),
)
}
}
/// Write a value to `localStorage`, or remove the key when `value` is `None`.
///
/// When `notify` is `True`, a synthetic `storage` event is dispatched so that
/// [`local_storage`](#local_storage) subscriptions are notified of the change
/// immediately.
///
/// See also: [local_storage](#local_storage), [set_session_storage](#set_session_storage)
pub fn set_local_storage(
key key: String,
value value: Option(String),
notify notify: Bool,
) -> Effect(message) {
case lustre.is_browser() {
True ->
effect.from(fn(_) {
case value {
Some(v) -> commands.set_local_storage(key, v, notify)
None -> commands.remove_local_storage(key, notify)
}
})
False ->
command("off-topic/set-local-storage", [
json.string(key),
case value {
Some(v) -> json.string(v)
None -> json.null()
},
json.bool(notify),
])
}
}
/// Write a value to `sessionStorage`, or remove the key when `value` is `None`.
///
/// When `notify` is `True`, a synthetic `storage` event is dispatched so that
/// [`session_storage`](#session_storage) subscriptions are notified of the
/// change immediately.
///
/// See also: [session_storage](#session_storage), [set_local_storage](#set_local_storage)
pub fn set_session_storage(
key key: String,
value value: Option(String),
notify notify: Bool,
) -> Effect(message) {
case lustre.is_browser() {
True ->
effect.from(fn(_) {
case value {
Some(v) -> commands.set_session_storage(key, v, notify)
None -> commands.remove_session_storage(key, notify)
}
})
False ->
command("off-topic/set-session-storage", [
json.string(key),
case value {
Some(v) -> json.string(v)
None -> json.null()
},
json.bool(notify),
])
}
}
fn bool_string_callback(
convert: fn(Bool, String) -> a,
to_msg: fn(a) -> message,
dispatch: fn(message) -> Nil,
) -> fn(Bool, String) -> Nil {
fn(b, s) { dispatch(to_msg(convert(b, s))) }
}
fn bool_string_decoder(
convert: fn(Bool, String) -> a,
to_msg: fn(a) -> message,
) -> Decoder(message) {
use b <- decode.field("0", decode.bool)
use s <- decode.field("1", decode.string)
decode.success(to_msg(convert(b, s)))
}
fn storage_value(exists: Bool, value: String) -> Option(String) {
case exists {
True -> Some(value)
False -> None
}
}
// -- TIME --------------------------------------------------------------------
/// Dispatch the current timestamp.
///
/// See also: [here](#here), [after](#after), [every](#every)
pub fn now(send message: fn(Timestamp) -> message) -> Effect(message) {
effect.from(fn(dispatch) { dispatch(message(timestamp.system_time())) })
}
/// Dispatch the current timezone offset, then again whenever it changes.
///
/// Always dispatches the browser's local timezone offset, even in server
/// components — the server's timezone is never used.
///
/// See also: [now](#now)
pub fn here(
on_timezone_offset handle_timezone_offset: fn(Duration) -> message,
) -> Subscription(message) {
case lustre.is_browser() {
True -> {
use dispatch <- from(watching: [dep("off-topic/here")])
use offset <- subscriptions.here
dispatch(handle_timezone_offset(duration.minutes(offset)))
}
False ->
remote(
name: "off-topic/here",
with: [],
run: decode.map(decode.int, fn(offset_minutes) {
handle_timezone_offset(duration.minutes(offset_minutes))
}),
)
}
}
/// Dispatch a message after a delay.
///
/// See also: [every](#every), [now](#now)
pub fn after(duration: Duration, send message: message) -> Effect(message) {
runtime.after(duration.to_milliseconds(duration), message)
}
/// Dispatch a message on a repeating interval.
///
/// The callback receives the current timestamp at each tick. When
/// `immediate` is `True`, the first tick fires immediately on subscribe;
/// otherwise the first tick fires after the first `interval` has elapsed.
///
/// See also: [after](#after)
pub fn every(
every interval: Duration,
immediate immediate: Bool,
on_elapsed handle_elapsed: fn(Timestamp) -> message,
) -> Subscription(message) {
let ms = duration.to_milliseconds(interval)
use dispatch <- from(watching: [dep(ms), dep(immediate)])
case immediate {
True -> dispatch(handle_elapsed(timestamp.system_time()))
False -> Nil
}
use <- subscriptions.timer(ms)
dispatch(handle_elapsed(timestamp.system_time()))
}
/// Dispatch a message when the user becomes idle or active.
///
/// Starts a timer that resets on any interaction with the current document.
/// Dispatches `True` when the timer elapses with no activity, and `False` when
/// activity resumes.
///
/// See also: [page_state](#page_state), [window_hover](#window_hover)
pub fn on_idle(
after timeout: Duration,
send message: fn(Bool) -> message,
) -> Subscription(message) {
let ms = duration.to_milliseconds(timeout)
case lustre.is_browser() {
True -> {
use dispatch <- from(watching: [dep(ms)])
subscriptions.on_idle(ms, fn(b) { dispatch(message(b)) })
}
False ->
remote(
"off-topic/on-idle",
[json.int(ms)],
decode.map(decode.bool, message),
)
}
}
/// Dispatch the current timestamp on every animation frame. <small>JS</small>
///
/// See also: [on_animation_frame_delta](#on_animation_frame_delta), [every](#every)
pub fn on_animation_frame(
send message: fn(Timestamp) -> message,
) -> Subscription(message) {
let _ = no_erlang()
runtime.animation(fn() { message(timestamp.system_time()) })
}
/// Dispatch the elapsed time since the last animation frame. <small>JS</small>
///
/// On the first frame the delta is zero, since there is no previous frame to
/// measure from.
///
/// See also: [on_animation_frame](#on_animation_frame)
pub fn on_animation_frame_delta(
send message: fn(Duration) -> message,
) -> Subscription(message) {
runtime.animation({
use last_time <- foldp(None)
let now = timestamp.system_time()
let last_time = option.unwrap(last_time, now)
#(Some(now), message(timestamp.difference(last_time, now)))
})
}
@external(javascript, "../gleam_stdlib/gleam/function.mjs", "identity")
fn no_erlang() -> Nil
@external(javascript, "./off_topic_ffi.mjs", "foldp")
fn foldp(
from state: state,
do callback: fn(state) -> #(state, result),
) -> fn() -> result
// -- CONNECTIONS -------------------------------------------------------------
/// Dispatch the current network status, then again whenever it changes.
///
/// Dispatches `True` when online and `False` when offline.
pub fn online(send message: fn(Bool) -> message) -> Subscription(message) {
use dispatch <- from(watching: [dep("off-topic/online")])
subscriptions.online(fn(b) { dispatch(message(b)) })
}
/// Subscribe to a Server-Sent Events stream. <small>JS</small>
///
/// Opens an `EventSource` at `url` and sends `on_message(id, data)` to your update
/// function for each incoming message. The connection is closed when the
/// subscription is torn down and reopened whenever `url` changes.
///
/// `on_open` fires on the initial connection and on every successful reconnect.
/// `on_error` fires when the connection is lost, before the next retry.
pub fn server_sent_events(
url url: String,
on_open opened: message,
on_message message: fn(String, String) -> message,
on_error errored: message,
) -> Subscription(message) {
use dispatch <- from(watching: [dep(url)])
subscriptions.sse(
url,
fn() { dispatch(opened) },
fn(id, data) { dispatch(message(id, data)) },
fn() { dispatch(errored) },
)
}
/// A frame received from a WebSocket connection.
pub type WebsocketMessage {
TextFrame(data: String)
BinaryFrame(data: BitArray)
}
/// Subscribe to a WebSocket connection. <small>JS</small>
///
/// Connects to `url` and dispatches messages for each event. Connections are
/// shared — multiple subscriptions to the same URL reuse a single socket.
/// The socket reconnects automatically it drops, as long as the subscription
/// is alive. The connection is closed when the last subscriber unsubscribes.
///
/// `on_open` fires on the initial connection and on every successful reconnect.
/// `on_error` fires when the connection is lost, before the next retry.
///
/// See also: [websocket_send](#websocket_send)
pub fn websocket(
url url: String,
on_open opened: message,
on_message message: fn(WebsocketMessage) -> message,
on_error errored: fn(String) -> message,
) -> Subscription(message) {
use dispatch <- from(watching: [dep(url)])
subscriptions.websocket(
url,
fn() { dispatch(opened) },
fn(text) { dispatch(message(TextFrame(text))) },
fn(bytes) { dispatch(message(BinaryFrame(bytes))) },
fn(reason) { dispatch(errored(reason)) },
)
}
/// Send a frame over an open WebSocket connection. <small>JS</small>
///
/// Does nothing if there is no active connection for `url` or if the socket
/// is not yet open.
///
/// See also: [websocket](#websocket)
pub fn websocket_send(
url url: String,
data data: WebsocketMessage,
) -> Effect(message) {
effect.from(fn(_) {
case data {
TextFrame(text) -> commands.websocket_send_text(url, text)
BinaryFrame(bytes) -> commands.websocket_send_binary(url, bytes)
}
})
}