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="{"timeout":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)emitsattr="value". -
@attremits a boolean attribute. -
@class(...)appends author classes after the generatedmdl-*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
- Website: https://getmdl.site
- Repository: https://github.com/tosiiko/mdl-code
- npm package: https://www.npmjs.com/package/@tosiiko/mdl
- htmx example:
examples/htmx - TypeScript example:
examples/typescript - Attribute spec:
docs/spec/ATTRIBUTES.md - Script spec:
docs/spec/SCRIPTS.md
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.
Top comments (0)