CalmUI
Components

Handle Component Clicks

Choose between game actions, parent events, and local component functions.

Choose the destination of the click first:

What should happen?Binding
Unreal runs gameplay code$action('MainMenu.Exit')
The parent View or component decides what happens$emit('click')
This component changes its own local UIA function such as toggle

Run Blueprint

Create MainMenu.Exit in Store → Action Contracts, then bind the button:

<button @click="$action('MainMenu.Exit')">Exit</button>

Follow Make a button run Blueprint to load the package, subscribe to the action, bind the event, and run Quit Game.

Notify the parent

Content forwarded through nested default or named slots keeps the original parent's data and event handlers. Wrapping a form in a confirmation dialog does not transfer ownership of its input events to the dialog. Keyed slot content keeps its identity when the caller reorders its rows.

A reusable button can emit a local event:

<button @click="$emit('click')">{{ props.label }}</button>

In the parent that imports MenuButton:

<MenuButton label="Settings" @click="openSettings" />

The parent's script must return an openSettings function. A local emitted event is not an Unreal action; the parent decides what to do with it.

A component instance can listen to more than one named custom event. Registering one listener no longer replaces another listener on that instance. Event names and payloads are routed independently. This does not add native input events; the host adapter only delivers the input events listed above.

Change local UI

Editable controls deliver input with the current string and change on Enter. Drag/drop delivers dragstart, dragenter, dragleave, drop and dragend. Elements with authored event bindings also deliver pointerenter and pointerleave at pointer boundaries, and focus/blur for keyboard/controller selection. The compiler makes an ordinary native element hit-testable when it owns such a binding, so HUD sections do not need a separate interaction marker. These are shared Core events, not browser DOM listeners. Keep descriptions open while either pointer or focus owns them; pointer leave alone need not close a focused card. New pointer-boundary bindings require matching AnchoredPopups-capable runtimes. See Scrolling and text entry for payloads and the reusable GameUI components.

Pointer boundaries are re-evaluated whenever the document changes, not only when the pointer moves. An element that appears or moves under a resting cursor receives pointerenter, and one that moves out from under it receives pointerleave, with no mouse movement required - a drawer that expands under the cursor, or a HUD chip whose timer text re-renders every second while you hover it, behaves the same as one you moved onto. Re-rendering an element you are hovering does not produce a spurious pointerleave/pointerenter pair. Unmounting a hovered element cannot deliver pointerleave to it, exactly as in the DOM, because the node no longer exists: clear any state you keyed to that element when it unmounts rather than relying on the leave.

An element that follows the pointer never becomes the hover target, so a cursor tooltip cannot take hover away from the element that opened it.

<button @click="toggle">Toggle details</button>

The component script must return toggle. See the complete local-state example.

Modifier keys and the event object

A bare handler such as @click="toggle" receives one argument: the Core event object. For click, pointerenter, pointerleave, focus and blur it carries type, x, y, modifiers (Shift = 1, Control = 2, Alt = 4), shiftKey, ctrlKey, altKey and button (0 primary, 1 middle, 2 secondary). The same value is available as $event inside a binding's payload expression, so a template can route Ctrl+click and Shift+click without a second button:

<button @click="activate">Use</button>
<button @click="$emit('activate', $event.ctrlKey)">Use</button>
<button @click="$action('Inventory.Activate', { slot: item.id, split: $event.ctrlKey })">Use</button>
function activate(event?: { ctrlKey: boolean; shiftKey: boolean }): void {
  if (event?.ctrlKey) openSplit();
  else if (event?.shiftKey) quickMove();
  else use();
}

input/change still deliver the control value and dragstart/drop/dragend still deliver the drag payload. $emit('name') and $action('Group.Name') without a payload expression do not receive the event object. The Unreal host reads the modifier state from Slate and Studio Preview forwards the browser's ctrlKey, shiftKey and altKey; a click reports the state at release time.

Accept and its default action

Gamepad/keyboard confirm (Enter, gamepad A) fires @accept before any default action, then synthesises a click with synthetic: true and source: "accept" so an @click handler still runs the same way a pointer click would. The Accept payload also carries gamepad: true/false, reporting whether the focused control currently has gamepad focus rather than keyboard focus, so a shared handler can tell a controller's A button apart from Enter.

An element with data-drag-key (an inventory slot, for example) has one more default action: if nothing marks the Accept event handled, Accept starts a keyboard/gamepad pickup drag of that item instead of only clicking it. This mirrors @back, which owns Back by stopping it from bubbling to the screen root, so a bound handler always runs before either default action fires; both now dispatch to the tree before deciding whether their default action still applies. @accept marks the default action rather than propagation, so ancestor listeners still see the event either way. At the runtime level a handler installed on calmui::DispatchedEvent calls event.PreventDefault() to suppress the pickup drag (the same object @back's handler calls StopPropagation() on), this is the native host-adapter contract a @accept binding is translated through.

A script handler bound directly to the event (no payload expression, e.g. @accept="onAccept") receives that same convention as $event: it is a UiEvent (see the field reference below) with live preventDefault() and stopPropagation() methods, plus a readable defaultPrevented. Calling $event.preventDefault(), or simply returning false from the handler, declines the drag the same way a native DispatchedEvent::PreventDefault() call does; declining still lets the synthesised click fire. A handler that does neither leaves the drag pickup working.

import type { UiEvent } from "@calmui/runtime";

// Enter/A opens the slot when it has a usable item; otherwise Accept falls
// through to the default keyboard/gamepad pickup drag.
function onAccept(event: UiEvent): void {
  if (canUse.value && useLabel.value) event.preventDefault();
}

@back accepts the same $event.stopPropagation() call for symmetry with @accept's preventDefault(), instead of relying only on the compiled .stop modifier (@back.stop="..."):

function onBack(event: UiEvent): void {
  if (isDirty.value) event.stopPropagation(); // keep the confirm-discard panel up
}

A handler bound with a payload expression (@accept="onAccept($event.x)") still receives that expression's evaluated result instead of the live UiEvent, since the result is not necessarily an object: preventDefault()/ stopPropagation() are only available on the raw $event a bare handler receives.

Secondary and tertiary activations

A gamepad has two more face buttons than Accept and Back use. @secondary (X / □ by default) and @tertiary (Y / △ by default) are the matching activations of the focused control: "□ Drop", "△ Compare" on an inventory slot, "△ Map" on a whole screen. Each press dispatches exactly one secondary or tertiary event to the focused control and lets it bubble to its ancestors the way accept does: a bound control does not own the event merely by being bound, so a screen root can listen for the same button behind every slot. There is no default action, no synthesised click, and no ui:hold-ms variant; a keyboard has no default chord (the host binding profile adds one, see Input and Studio's Shift+Enter / Alt+Enter).

<button data-testid="slot"
        @accept="use"
        @secondary="drop"
        @tertiary="$emit('compare', { slot: item.id, gamepad: $event.gamepad })">
  {{ item.name }}
</button>

The payload is the same UiEvent object @accept receives: type is "secondary" / "tertiary", gamepad reports whether the focused control has gamepad focus (a face button) rather than keyboard focus (a bound chord), and synthetic is always true because neither activation ever comes from a pointer. A bare script handler can call $event.stopPropagation() to own the event instead of letting it reach an ancestor (the compiled .stop modifier, @secondary.stop="...", does the same without a script); preventDefault() is accepted for symmetry and reflected in defaultPrevented, but there is no default action to decline.

import type { UiEvent } from "@calmui/runtime";

// □ on a slot drops the item and owns the press; an empty slot lets the
// screen's own @secondary ("□ Sort") run instead.
function drop(event: UiEvent): void {
  if (!item.value) return;
  inventory.drop(item.value.id);
  event.stopPropagation();
}

When nothing is focused (a freshly opened screen before the first directional input), the event is routed like Back: to the topmost busy modal barrier if there is one, otherwise to the topmost visible root, so a screen-level @tertiary="openMap" still fires. Unlike Back it never falls through to the document. Studio's Interaction Testing panel has X and Y buttons next to A and B, and the Unreal automation harness drives them with the secondary / tertiary steps (fixture R4SecondaryActivation).

Hold to activate

Any focusable or clickable element may declare ui:hold-ms="2000" to require a held activation instead of a tap - a confirm-destructive "hold to delete" button, a "hold to respawn" prompt, a long-press pickup. While the activation input is held - the primary pointer button pressed on the element (or on one of its children), or Accept pressed while the element is focused (Enter, Space, the gamepad face button) - the engine tracks the elapsed UI time. When it reaches the authored duration the element's normal activation is dispatched exactly once: @accept (for the Accept path) followed by the same synthesised click an instant Accept produces, or a real click for the pointer path. The payload is the one the element gets today plus hold: true, so an existing handler keeps working and one that cares can tell a held confirmation apart.

Releasing early cancels the hold with no event. An element with ui:hold-ms never activates on a plain press/release - a short tap does nothing at all - and sliding the pointer off the element, moving focus away, or a document patch that disables/unmounts the element also cancels silently. The value is a literal number of milliseconds (1 .. 60000, diagnostic CUI2141 otherwise) or a binding (:ui:hold-ms="props.holdMs"); put it on the native control, not on a component tag.

<button ui:hold-ms="1500" @click="$action('Save.Delete', { slot: item.id, hold: $event.hold })">
  <span class="hold-fill"></span>
  <span>Hold to delete</span>
</button>

During the hold the element carries the :holding pseudo-class, which patches like :active (background, border, colour, opacity, transform, shadows, ...), and publishes the live progress (0 .. 1, quantized to hundredths) as the custom property --calmui-hold-progress on itself and its descendants. Author CSS reads it through the interactive-style path, which is what lets a fill or ring follow the hold without a script:

.hold-fill { transform: scaleX(0); transform-origin: left center; opacity: .35; }
button:holding { border-color: var(--color-warning); }
button:holding .hold-fill { transform: scaleX(var(--calmui-hold-progress)); transform-origin: left center; }

Studio's Interaction Testing panel can force :holding (previewing the fully charged look, progress 1); Enter/Space in the preview start the hold on key down and cancel on key up, and the mouse works the same way as in the game.

Event object reference

FieldTypeNotes
typestringe.g. "click", "accept", "back", "secondary", "tertiary"
x, ynumberPointer position
modifiersnumberUiModifierKeys bitmask
shiftKey, ctrlKey, altKeyboolean
buttonnumber0 primary, 1 middle, 2 secondary
syntheticboolean?click: true for the click Accept synthesises; always true on secondary/tertiary
sourcestring?"accept" on a synthesised click
gamepadboolean?accept/secondary/tertiary; gamepad focus (face button) vs. keyboard focus (chord)
holdboolean?click/accept; true when a ui:hold-ms hold completed
wheelDeltanumber?wheel only
keynumber?keydown/keyup only
directionstring?navigation only: "left", "right", "up" or "down"
defaultPreventedbooleanTrue once preventDefault() has been called
preventDefault()() => voidDeclines the event's default action
stopPropagation()() => voidOwns the event instead of letting it bubble

On this page