Events

In Doors, DOM events are handled through special attributes that connect a browser event to a Go handler.

Around that handler, you also manage client-side scheduling, pending indication, and follow-up actions.

Start

Most event attrs look like doors.AClick, doors.AInput, or doors.ASubmit[T].

The smallest useful example is:

<button
	(doors.AClick{
		On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
			return false
		},
	})>
	Click
</button>

The core shape is: an event attr has an On handler, and that handler returns bool.

Return:

  • false to keep the handler active
  • true to mark it done and remove it

For normal DOM events, false is the common default.

Attach

Event attributes are regular Go values with names like doors.AClick, doors.AInput, or doors.ASubmit[T].

You can attach them in two ways.

As an attribute modifier:

<button
	(doors.AClick{
		On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
			return false
		},
	})>
	Click
</button>

Or as a proxy:

<>
	~>doors.AClick{
		On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
			return false
		},
	} <button>Click</button>
</>

The modifier form attaches directly to the element you are editing.

The proxy form walks through the following subtree until it reaches the real rendered element and attaches there. That is useful when the final element is inside another component.

In general, there is no strict rule that says you should use a proxy only when you need to drill into a component to find the target. Use proxy syntax everywhere if you prefer it.

Handler

For normal DOM events, the handler receives ctx and doors.RequestEvent[T].

That gives you:

  • r.Event() for the typed event payload
  • r.Context() for the underlying HTTP request context
  • r.SetCookie(...) and r.GetCookie(...)
  • r.After(...) to schedule client-side actions after the request succeeds and the DOM changes it triggered are applied

The ctx parameter is the Doors runtime context. Pass that ctx to Doors APIs. r.Context() is separate: it belongs to the HTTP request.

Example:

On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
	r.After(doors.ActionScroll{Selector: "#top"})
	return false
}

Form handlers use:

  • doors.RequestForm[T] for decoded form data
  • doors.RequestRawForm for raw multipart access, including SetRequestBodyLimit(limit int) for per-request limits

Event and form handler request bodies are bounded by ServerRequestBodyLimit (default 8 MB). For ASubmit, this value is also used as the max memory limit for form parsing. Use SetRequestBodyLimit on a raw form request to override the limit per request, or set a negative value to disable it entirely.

Options

Most event attrs share the same request-lifecycle fields:

  • On: backend handler
  • Scope: request scheduling rules, covered in Scopes
  • Indicator: temporary client-side feedback, covered in Indication
  • Before: client-side actions before the request
  • OnError: client-side actions if the request fails
  • Race: how overlapping calls to the handler are handled on the backend, covered in Race
  • Batch: apply the handler's page updates together, covered in Batch

After is different: it is not an attribute field. You schedule it from inside the handler with r.After(...).

Some event families also add browser-event options such as:

  • PreventDefault
  • StopPropagation
  • ExactTarget
  • Key
  • ExcludeValue

Not every event family supports every one of these options.

Form and custom-hook attrs — ASubmit[T], ARawSubmit, AHook[...], and ARawHook — also accept RequestTimeout, which overrides the configured RequestTimeout for that attr's requests, for example when uploads need longer than the default. Regular event attrs such as AClick do not have it.

Flow

When an event fires, the client/runtime flow is roughly:

  1. capture the browser event and build the payload
  2. apply client-side event options such as PreventDefault, StopPropagation, ExactTarget, or keyboard Key matching
  3. run client-side scopes
  4. start indication
  5. run any Before actions
  6. send the request to the server
  7. run the Go handler
  8. run After actions if the request succeeds
  9. run OnError actions if the request fails

That is why scopes and indication feel immediate: they start on the client before the server finishes the request.

A handler that hands work to a goroutine can keep the request pending past its return with doors.HoldSettle(ctx): the client keeps the indicator and the scope until the returned release function is called. See Door.

The indicator, the scope, After actions, and the $hook promise do not wait for content passed to deferred Door operations (DeferredInner, DeferredOuter, DeferredStatic).

Pointer

Pointer attributes include:

  • doors.AClick
  • doors.APointerDown
  • doors.APointerUp
  • doors.APointerMove
  • doors.APointerOver
  • doors.APointerOut
  • doors.APointerEnter
  • doors.APointerLeave
  • doors.APointerCancel
  • doors.AGotPointerCapture
  • doors.ALostPointerCapture

Example:

<button
	(doors.AClick{
		PreventDefault: true,
		On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
			x := r.Event().PageX()
			y := r.Event().PageY()
			_ = x
			_ = y
			return false
		},
	})>
	Track click
</button>

The pointer payload includes the usual browser pointer fields — button state, pointer type, pressure, and timestamp — plus geometry rectangles. Coordinate accessors such as PageX(), ClientX(), OffsetX(), and ScreenX(), with their Y counterparts, derive the usual coordinates from them.

Keyboard

Keyboard attributes are:

  • doors.AKeyDown
  • doors.AKeyUp

Use Key to fire only for specific keys and modifier combinations.

Key takes a single doors.Keys value, and a doors.Key is one on its own. Combine several with .And(...) or doors.JoinKeys(...); the hook fires when the event matches any of them:

<input
	(doors.AKeyDown{
		Key: doors.Key{Key: "Enter"}.
			And(doors.Key{Key: "s", CtrlMod: doors.ModOn}),
		On: func(ctx context.Context, r doors.RequestEvent[doors.KeyboardEvent]) bool {
			return false
		},
	})/>

Each doors.Key matches event.key (an empty string matches any key) plus a required state for each modifier. CtrlMod, ShiftMod, AltMod, and MetaMod are doors.Mod values:

  • doors.ModAny (default) — modifier ignored
  • doors.ModOn — modifier must be held
  • doors.ModOff — modifier must not be held

So doors.Key{Key: "Enter"} matches Enter with any modifiers, and doors.Key{Key: "s", CtrlMod: doors.ModOn} matches Ctrl+S regardless of Shift, Alt, or Meta. To require an exact combination, set the other modifiers to doors.ModOff.

The keyboard payload includes Key, Code, Repeat, and modifier state such as CtrlKey, ShiftKey, AltKey, and MetaKey.

Focus

Focus attributes are:

  • doors.AFocus
  • doors.ABlur
  • doors.AFocusIn
  • doors.AFocusOut

Use AFocusIn and AFocusOut when bubbling behavior matters.

Use AFocus and ABlur for the plain focus events.

Input

Input-related attributes are:

  • doors.AInput
  • doors.AChange

AInput fires as the user edits.

AChange fires when the value is committed.

<input
	type="text"
	(doors.AInput{
		On: func(ctx context.Context, r doors.RequestEvent[doors.InputEvent]) bool {
			value := r.Event().Value
			_ = value
			return false
		},
	})/>

The input and change payloads include browser-style fields such as:

  • Name
  • Value
  • Number
  • Date
  • Selected
  • Checked

AInput{ExcludeValue: true} omits the normal input-derived fields from the payload. Use it when you want the event itself without sending the current input value data.

Forms

Form submission attributes are:

  • doors.ASubmit[T]
  • doors.ARawSubmit

ASubmit[T] parses the multipart form and decodes it into your Go type.

type LoginForm struct {
	Email string `form:"email"`
	Code  string `form:"code"`
}

<form
	(doors.ASubmit[LoginForm]{
		On: func(ctx context.Context, r doors.RequestForm[LoginForm]) bool {
			data := r.Data()
			_ = data
			return false
		},
	})>
	<input name="email"/>
	<input name="code"/>
	<button>Send</button>
</form>

Use ARawSubmit when you want direct multipart access for streaming, custom parsing, or uploads.

For form decoding, Doors uses go-playground/form v4.

Reuse

Use doors.A(ctx, ...) when you want to prepare one activated attribute value and reuse it.

<>
	~~
	radio := doors.A(ctx, doors.AChange{
		On: func(ctx context.Context, r doors.RequestEvent[doors.ChangeEvent]) bool {
			return false
		},
	})
	~~

	<input type="radio" name="pick" value="a" (radio)/>
	<input type="radio" name="pick" value="b" (radio)/>
</>

For a one-off attribute on one element, you usually do not need doors.A(...).

Each activated event attr has its own backend hook instance.

By default, calls to that same instance run one at a time, so rapid repeated events on one active handler do not run concurrently on the backend. Race changes that.

If you reuse one activated attr across several elements, those elements also share the same hook instance and the same execution queue.

Race

Requests to one handler can overlap, and they can overtake each other on the way to the server. Race sets how the backend handles that:

  • doors.RaceSerial (default): calls run one at a time, in order of arrival
  • doors.RaceParallel: calls run concurrently
  • doors.RaceStrict: calls run one at a time, and out-of-order calls are canceled

With RaceParallel the handler must synchronize any shared state itself. Returning true stops new calls, and the handler is removed once the calls already running finish.

With RaceStrict a call that arrives after a newer one is canceled instead of run, so an older call never overwrites the result of a newer one. Nothing is reordered, and calls that arrive in order all run. Use it when only the newest input matters: live search, autosave, selection.

<input
	(doors.AInput{
		Race: doors.RaceStrict,
		On: func(ctx context.Context, r doors.RequestEvent[doors.InputEvent]) bool {
			return false
		},
	})/>

A canceled call is silent on event attrs: OnError does not run.

Race acts on the backend, after a request arrives. Scopes act on the client, before it is sent. They combine: ScopeLatest keeps the newest interaction in control of the UI, RaceStrict keeps an older call from running after a newer one.

Batch

Every Door renders on its own, and its update goes to the browser as soon as it is ready. That keeps pages responsive, but a handler that changes several places, or replaces content that takes a moment to produce, changes the page piece by piece. Batch: true groups only the paint: the handler runs and renders as before, and the browser applies its updates together when it finishes.

Use it for an action that changes several stacked or nested components, and for a link to a view that takes a moment to load, see Navigation.

<>
	~>doors.AClick{
		Batch: true,
		On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
			p.list.Inner(ctx, OrderList{status: status})
			p.count.Inner(ctx, OrderCount{status: status})
			return false
		},
	} <button>Show ~(status)</button>
</>

The batch covers everything the handler starts with its context: Door updates, the state propagation they trigger, and path changes. It closes when the handler has finished and everything it started is queued; doors.HoldSettle keeps it open until release, see Door. Deferred operations (DeferredInner, DeferredOuter, DeferredStatic) are not part of the batch: their content lands on its own, after the batch when it targets a Door the batch updates.

The view kept on screen during a batch is display only: interactions with parts that are being replaced are ignored until the new view lands, so put an indicator on the trigger.

Completion channels in a handler report only that an operation was scheduled, batch or not. For a result from the browser, wait in a goroutine of your own with doors.DetachedContext(ctx), whose operations are delivered outside the batch, see Detached Context. To react once the batch is applied, use doors.OnBatch, see Lifecycle Hooks.

Title and meta updates are not batched.

Batches are bounded: when the calls held in the browser reach SolitairePending, the oldest batch is applied early, see Configuration.

Event Presets

Event attrs carry many fields. When the same Scope, Indicator, and OnError appear on every handler, a preset keeps that boilerplate in one place.

A preset is a small struct implementing doors.Attr. It holds only the fields that vary per use and constructs the full event attr in Proxy and Modify:

var _ doors.Attr = SaveClick{}

type SaveClick struct {
	On func(context.Context, doors.RequestEvent[doors.PointerEvent]) bool
}

func (p SaveClick) Proxy(cur gox.Cursor, elem gox.Elem) error {
	return p.click().Proxy(cur, elem)
}

func (p SaveClick) Modify(ctx context.Context, tag string, attrs gox.Attrs) error {
	return p.click().Modify(ctx, tag, attrs)
}

func (p SaveClick) click() doors.AClick {
	return doors.AClick{
		Scope:     &doors.ScopeBlocking{},
		Indicator: doors.IndicateAttr("aria-busy", "true"),
		OnError: doors.ActionIndicate{
			Indicator: doors.IndicateClass("error"),
			Duration:  300 * time.Millisecond,
		},
		On: p.On,
	}
}

Use it like any other event attr:

<button (SaveClick{
	On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
		// save logic
		return false
	},
})>Save</button>

The same pattern works for any event attr family — forms, keyboard, input and links.

Unsupported

If the browser event you need is not supported by the built-in doors.A... event attributes, wire it yourself in JavaScript and call a custom hook.

That is the normal extension path for:

  • browser events Doors does not expose directly
  • custom DOM integrations
  • third-party widgets that already have their own client-side event system

The usual pattern is:

  1. listen to the event in JavaScript
  2. call $hook(...) or $fetch(...)
  3. handle it on the Go side with doors.AHook[...] or doors.ARawHook

Example:

<script
	(doors.AHook[string]{
		Name: "visibility",
		On: func(ctx context.Context, r doors.RequestHook[string]) (any, bool) {
			println(r.Data())
			return nil, false
		},
	})>
	document.addEventListener("visibilitychange", async () => {
		await $hook("visibility", document.visibilityState)
	})
</script>

See JavaScript.

Rules

  • Use the ctx that Doors gives you in the handler.
  • Use false as the normal return value unless the handler really should be one-shot.
  • Use AInput for live edits and AChange for committed values.
  • Use ASubmit[T] when typed decoding is enough; use ARawSubmit for upload-heavy or custom multipart flows.
  • Use scopes for interaction policy instead of rebuilding debounce/blocking logic by hand.
  • Use indication when the user needs immediate feedback before the server responds.