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 UI | A 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
| Field | Type | Notes |
|---|---|---|
type | string | e.g. "click", "accept", "back", "secondary", "tertiary" |
x, y | number | Pointer position |
modifiers | number | UiModifierKeys bitmask |
shiftKey, ctrlKey, altKey | boolean | |
button | number | 0 primary, 1 middle, 2 secondary |
synthetic | boolean? | click: true for the click Accept synthesises; always true on secondary/tertiary |
source | string? | "accept" on a synthesised click |
gamepad | boolean? | accept/secondary/tertiary; gamepad focus (face button) vs. keyboard focus (chord) |
hold | boolean? | click/accept; true when a ui:hold-ms hold completed |
wheelDelta | number? | wheel only |
key | number? | keydown/keyup only |
direction | string? | navigation only: "left", "right", "up" or "down" |
defaultPrevented | boolean | True once preventDefault() has been called |
preventDefault() | () => void | Declines the event's default action |
stopPropagation() | () => void | Owns the event instead of letting it bubble |