VOOZH about

URL: https://dev.to/tosiiko/every-mdl-behavior-attribute-for-htmx-and-typescript-5401

⇱ Every MDL behavior attribute for htmx and TypeScript - DEV Community


MDL has two small behavior paths:

MDL behavior attributes -> htmx hx-* output
MDL event attributes -> exported JavaScript or TypeScript handlers

That means the source can stay compact:

form@api(post /api/profile)@result(profileResult)@swap(replace)@trigger(submit)@loading(profileBusy):
 .input@id(profileName)@name(name)@required
 .btn-primary@type(submit)(Save)
 status@id(profileBusy):
 Saving.

With the htmx adapter enabled, MDL emits validated htmx attributes:

<form
 class="mdl-form"
 hx-post="/api/profile"
 hx-target="#profileResult"
 hx-swap="outerHTML"
 hx-trigger="submit"
 hx-indicator="#profileBusy">
</form>

And with configured JavaScript or TypeScript modules, event attributes call
exported functions by name:

card@id(plannerPanel)@mount(mountPlanner):
 .btn-primary@click(addTask)(Add task)
export function mountPlanner(element: HTMLElement) {
 element.dataset.ready = "true";
}

export function addTask(event: MouseEvent) {
 event.preventDefault();
}

Enable the htmx adapter

Configure htmx as the behavior adapter in mdl.json:

{"head_scripts":["https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"],"behavior":{"adapter":"htmx","version":"2"}}

MDL source should use MDL behavior attributes such as @api(...),
@result(...), and @swap(...). Raw @hx-post(...), @hx-target(...), and
other raw htmx attributes are not emitted.

htmx behavior attribute map

These are the MDL behavior attributes supported by the htmx adapter.

MDL attribute htmx output Example
@api(get /api/search) hx-get="/api/search" get, post, put, patch, delete
@result(searchResults) hx-target="#searchResults" Targets this, id, #id, or .class
@swap(inner) hx-swap="innerHTML" See swap values below
@trigger(input) hx-trigger="input" Uses a known trigger event
@loading(searchBusy) hx-indicator="#searchBusy" Also accepts this or #id
@confirm(Save changes?) hx-confirm="Save changes?" Plain text prompt, max 200 chars
@include(csrfToken) hx-include="#csrfToken" Also accepts this or #id
@select(resultFragment) hx-select="#resultFragment" Selects one response fragment
@select-oob(toast) hx-select-oob="#toast" Selects one out-of-band fragment
@swap-oob(true) hx-swap-oob="true" Also accepts known swap values
@push(false) hx-push-url="false" true, false, or safe root path
@replace(/profile) hx-replace-url="/profile" true, false, or safe root path
@history(false) hx-history="false" true or false
@history-elt(true) hx-history-elt="true" Emits only for true
@boost(true) hx-boost="true" Only on safe local links/forms
@disabled(saveButton) hx-disabled-elt="#saveButton" Also accepts this or #id
@disinherit(target swap) hx-disinherit="hx-target hx-swap" Allows known htmx attrs or *
@encoding(multipart) hx-encoding="multipart/form-data" Also supports urlencoded form encoding
@inherit(trigger) hx-inherit="hx-trigger" Allows known htmx attrs
@params(name email) hx-params="name,email" Explicit names or none
@preserve(true) hx-preserve="true" Requires a safe @id(...)
@prompt(Security code?) hx-prompt="Security code?" Plain text prompt, max 200 chars
@request(timeout=5000) hx-request="{&quot;timeout&quot;:5000}" Timeout from 1 to 60000 ms
@sync(profileForm:queue-last) hx-sync="#profileForm:queue last" Coordinates concurrent requests
@validate(true) hx-validate="true" true or false

Supported @api(...) methods

get
post
put
patch
delete

@api(...) only emits same-origin-safe paths. External API URLs are not
emitted from this attribute.

Supported @swap(...) values

MDL gives short names to the htmx swap values:

MDL htmx
inner innerHTML
inner-html innerHTML
innerhtml innerHTML
replace outerHTML
outer outerHTML
outer-html outerHTML
outerhtml outerHTML
append beforeend
prepend afterbegin
before beforebegin
after afterend
none none

@swap-oob(...) supports true plus the same known swap values. false is not
emitted for @swap-oob(...).

Supported @trigger(...) values

submit
click
change
input
load
revealed
intersect
keyup
keydown
focus
blur
mouseenter
mouseleave

MDL intentionally does not emit htmx trigger filters or JavaScript-like trigger
expressions from @trigger(...).

Supported @encoding(...) values

multipart
multipart/form-data
form
urlencoded
application/x-www-form-urlencoded

These normalize to either multipart/form-data or
application/x-www-form-urlencoded.

Supported @sync(...) strategies

drop
abort
replace
queue
queue-first
queue-last
queue-all

You can pass the strategy by itself:

form@sync(queue-last):

Or scope it to one explicit target:

form@sync(profileForm:queue-last):

Supported @inherit(...) and @disinherit(...) names

These attributes accept MDL names or htmx names. MDL normalizes them to htmx
attribute names.

target / result -> hx-target
swap -> hx-swap
trigger -> hx-trigger
indicator / loading -> hx-indicator
confirm -> hx-confirm
include -> hx-include
select -> hx-select
select-oob -> hx-select-oob
swap-oob -> hx-swap-oob
push / push-url -> hx-push-url
replace / replace-url -> hx-replace-url
history -> hx-history
history-elt -> hx-history-elt
boost -> hx-boost
disabled / disabled-elt -> hx-disabled-elt
disinherit -> hx-disinherit
encoding -> hx-encoding
inherit -> hx-inherit
params -> hx-params
preserve -> hx-preserve
prompt -> hx-prompt
request -> hx-request
sync -> hx-sync
validate -> hx-validate
get -> hx-get
post -> hx-post
put -> hx-put
patch -> hx-patch
delete -> hx-delete

@disinherit(*) is also supported. @inherit(*) is not.

Configure TypeScript handlers

MDL scripts can point at JavaScript or TypeScript module entries:

{"scripts":["scripts/app.ts"]}

During mdl build, MDL compiles local .ts module scripts to browser-ready
JavaScript:

scripts/app.ts -> dist/scripts/app.js

The generated HTML imports the JavaScript URL:

<script type="module">
import * as mdlModule0 from "./scripts/app.js";
</script>

During mdl serve, the browser still asks for ./scripts/app.js, and the dev
server serves JavaScript compiled from scripts/app.ts.

Configured TypeScript entries can import local TypeScript trees:

scripts/app.ts
scripts/state/store.ts
scripts/dom/render.ts
scripts/utils/format.ts

MDL preserves the folder tree in output:

dist/scripts/app.js
dist/scripts/state/store.js
dist/scripts/dom/render.js
dist/scripts/utils/format.js

Local TypeScript imports may use .ts, omit the extension, or use the
browser-facing .js extension:

import { snapshot } from "./state/store.ts";
import { renderTaskList } from "./dom/render.js";
import { formatCount } from "./utils/format";
import type { Task } from "./state/model.ts";

MDL does not create package.json, node_modules, or tsconfig.json for this.
It is transpile-only behavior support, not a bundler or project type checker.

Inline TypeScript blocks are not supported yet:

script ts:
 // not supported yet

Use inline JavaScript:

script js:
 document.body.dataset.ready = "true"

Or an external configured .ts module.

TypeScript event attributes

Event attributes compile to data-mdl-on-* markers, and MDL's generated module
runtime calls exported handlers by name.

form@submit(createTaskFromForm):
 .input@input(updateDraftPreview)
 .btn-primary@click(saveTask)(Save)

canvas@id(scene)@mount(drawScene):
<form class="mdl-form" data-mdl-on-submit="createTaskFromForm">
 <input class="mdl-input" data-mdl-on-input="updateDraftPreview">
 <button class="mdl-btn-primary" data-mdl-on-click="saveTask">Save</button>
</form>
<canvas class="mdl-canvas" id="scene" data-mdl-on-mount="drawScene"></canvas>

@mount(handler) is not a browser event. It runs once after configured modules
are imported and receives the mounted element.

All other handlers receive the browser event.

Complete event alias list

These event attributes can be handled from JavaScript or TypeScript modules:

@click @dblclick @auxclick @contextmenu
@command
@submit @reset @formdata @beforeinput
@input @change @invalid @search
@select
@compositionstart @compositionupdate @compositionend
@focus @blur @focusin @focusout
@keydown @keypress @keyup
@mousedown @mouseup @mousemove @mouseover
@mouseout @mouseenter @mouseleave
@pointerdown @pointerup @pointermove @pointerover
@pointerout @pointerenter @pointerleave @pointercancel
@pointerrawupdate @gotpointercapture @lostpointercapture
@touchstart @touchmove @touchend @touchcancel
@wheel @scroll @scrollend
@load @error @abort @resize
@canplay @canplaythrough @play @playing
@pause @ended @durationchange @emptied
@loadeddata @loadedmetadata @loadstart @progress
@ratechange @seeked @seeking @stalled
@suspend @timeupdate @volumechange @waiting
@dragstart @drag @dragenter @dragover
@dragleave @drop @dragend
@copy @cut @paste
@cuechange @slotchange
@toggle @beforetoggle @beforematch @close
@cancel @contextlost @contextrestored
@securitypolicyviolation
@fullscreenchange @fullscreenerror
@animationstart @animationiteration @animationend @animationcancel
@transitionrun @transitionstart @transitioncancel @transitionend

TypeScript handler example

form@id(taskForm)@submit(createTaskFromForm):
 .input@id(taskTitle)@name(title)@type(text)@required@input(updateDraftPreview)
 .btn-primary@type(submit)(Add task)

card@id(plannerPanel)@mount(mountPlanner):
 status@id(boardStatus)@aria-live(polite):
 Waiting for TypeScript.
export function mountPlanner(element: HTMLElement) {
 element.dataset.ready = "true";
}

export function createTaskFromForm(event: SubmitEvent) {
 event.preventDefault();

 const form = event.currentTarget;
 if (!(form instanceof HTMLFormElement)) return;

 const data = new FormData(form);
 const title = String(data.get("title") ?? "").trim();
 if (!title) return;

 // Update app state, then render.
}

export function updateDraftPreview(event: Event) {
 const input = event.currentTarget;
 if (!(input instanceof HTMLInputElement)) return;

 const preview = document.querySelector("#draftPreview");
 if (preview) preview.textContent = input.value;
}

General MDL attribute rules

Normal HTML attributes are still direct:

.input@id(email)@name(email)@type(email)@required@autocomplete(email)

That emits:

<input class="mdl-input" id="email" name="email" type="email" required autocomplete="email">

The core rules are:

  • @attr(value) emits attr="value".
  • @attr emits a boolean attribute.
  • @class(...) appends author classes after the generated mdl-* class.
  • Raw browser event attributes such as @onclick(...) are not emitted.
  • Raw htmx attributes such as @hx-post(...) are not emitted.
  • Inline @style(...) and iframe @srcdoc(...) are not emitted.
  • URL attributes are filtered to safe URL shapes.

The reason is boring in the best way: MDL keeps authoring short, but the output
should remain inspectable and hard to accidentally turn into a script sink.

Try the examples

The htmx example app:

node examples/htmx/server.mjs
target/debug/mdl serve examples/htmx

Open:

http://127.0.0.1:4010

The TypeScript example:

cd examples/typescript
../../bin/mdl serve

Open:

http://127.0.0.1:3996

Links

MDL is still early, but the shape is already useful: htmx gets validated
hypermedia attributes, TypeScript gets typed behavior modules, and the browser
still receives plain HTML, CSS, and JavaScript.