DEV Community

Tosiiko
Tosiiko

Posted on

Every MDL behavior attribute for htmx and TypeScript

MDL has two small behavior paths:

MDL behavior attributes -> htmx hx-* output
MDL event attributes    -> exported JavaScript or TypeScript handlers
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
export function mountPlanner(element: HTMLElement) {
  element.dataset.ready = "true";
}

export function addTask(event: MouseEvent) {
  event.preventDefault();
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

@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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You can pass the strategy by itself:

form@sync(queue-last):
Enter fullscreen mode Exit fullscreen mode

Or scope it to one explicit target:

form@sync(profileForm:queue-last):
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Configure TypeScript handlers

MDL scripts can point at JavaScript or TypeScript module entries:

{
  "scripts": [
    "scripts/app.ts"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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

scripts/app.ts -> dist/scripts/app.js
Enter fullscreen mode Exit fullscreen mode

The generated HTML imports the JavaScript URL:

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Use inline JavaScript:

script js:
  document.body.dataset.ready = "true"
Enter fullscreen mode Exit fullscreen mode

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):
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

@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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode

General MDL attribute rules

Normal HTML attributes are still direct:

.input@id(email)@name(email)@type(email)@required@autocomplete(email)
Enter fullscreen mode Exit fullscreen mode

That emits:

<input class="mdl-input" id="email" name="email" type="email" required autocomplete="email">
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Open:

http://127.0.0.1:4010
Enter fullscreen mode Exit fullscreen mode

The TypeScript example:

cd examples/typescript
../../bin/mdl serve
Enter fullscreen mode Exit fullscreen mode

Open:

http://127.0.0.1:3996
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)