Ctrl + K

Events

Handling events in Pulsar.

Event Handling

Events in Pulsar are explicit, synchronous and owned by components. They represent user intent and are handled through regular Dart methods.

Pulsar does not introduce synthetic events, signal layers or automatic binding mechanisms. An event is simply a DOM Event object passed to a method you define.

Handling Events

Events are handled by defining a method on the component that receives an Event parameter. The method reference is then passed to the corresponding event attribute.

final class Clicker extends Component {
  int count = 0;

  void handleClick(Event event) {
    morph(() => count++);
  }

  @override
  Morphic render() {
    return Button().onClick(handleClick)(['Clicked $count times']);
  }
}

The handleClick method receives the DOM Event. When the button is clicked, the method runs, state updates via morph(), and the UI re-renders.

Important: Event handlers are passed as method references, not invoked. Use onClick(handleClick), NOT onClick(handleClick()).

Common Event Types

Pulsar exposes a predictable set of DOM-aligned events. These map directly to standard browser events.

Mouse Events

final class MouseExample extends Component {
  void handleClick(Event e) {
    print('Clicked');
  }
  
  void handleDoubleClick(Event e) {
    print('Double clicked');
  }
  
  void handleMouseEnter(Event e) {
    print('Mouse entered');
  }
  
  void handleMouseLeave(Event e) {
    print('Mouse left');
  }

  @override
  Morphic render() {
    return Div()
      .onClick(handleClick)
      .onDoubleClick(handleDoubleClick)
      .onMouseEnter(handleMouseEnter)
      .onMouseLeave(handleMouseLeave)
      (['Interactive Area']);
  }
}

Form Events

final class FormExample extends Component {
  String inputValue = '';
  
  void handleInput(Event e) {
    final target = e.target as HTMLInputElement;
    //  NO morph - prevents losing focus
    inputValue = target.value;
  }
  
  void handleChange(Event e) {
    final target = e.target as HTMLInputElement;
    morph(() => inputValue = target.value);
  }
  
  void handleSubmit(Event e) {
    e.preventDefault();
    morph(() {
      print('Submitted: $inputValue');
      inputValue = '';
    });
  }

  @override
  Morphic render() {
    return Form().onSubmit(handleSubmit)([
      Input()
        .value(inputValue)
        .onInput(handleInput)  // Updates without morph
        ([]),
      Button().type(ButtonType.submit)(['Submit']),
    ]);
  }
}

Critical Pattern: Use onInput without morph() to update input values. This prevents losing focus while typing. Use onChange or onSubmit with morph() when you need to trigger re-renders.

Keyboard Events

final class KeyboardExample extends Component {
  String lastKey = '';
  
  void handleKeyDown(Event e) {
    final keyEvent = e as KeyboardEvent;
    
    if (keyEvent.key == 'Enter') {
      morph(() => lastKey = 'Enter pressed');
    } else if (keyEvent.key == 'Escape') {
      morph(() => lastKey = 'Escape pressed');
    } else {
      morph(() => lastKey = keyEvent.key);
    }
  }

  @override
  Morphic render() {
    return Div()([
      Input().onKeyDown(handleKeyDown)([]),
      P()(['Last key: $lastKey']),
    ]);
  }
}

Working with the Event Object

The Event object passed to your handler is the standard DOM Event. You can access its properties and methods directly.

final class EventDetails extends Component {
  void handleClick(Event e) {
    // Prevent default behavior
    e.preventDefault();
    
    // Stop event propagation
    e.stopPropagation();
    
    // Access target element
    final target = e.target as HTMLElement;
    print('Clicked element: ${target.tagName}');
    
    // Mouse event specific
    if (e is MouseEvent) {
      print('Click position: ${e.clientX}, ${e.clientY}');
    }
    
    morph(() {
      // Update state
    });
  }

  @override
  Morphic render() {
    return Button().onClick(handleClick)(['Click me']);
  }
}

Parent-Child Communication

Events flow upward through callbacks. This is the primary mechanism for child-to-parent communication.

final class Parent extends Component {
  int count = 0;

  void increment(Event event) {
    morph(() => count++);
  }
  
  void decrement(Event event) {
    morph(() => count--);
  }

  @override
  Morphic render() {
    return Div().classes("parent")([
      H2()(['Count: $count']),
      Controls(
        onIncrement: increment,
        onDecrement: decrement,
      ),
    ]);
  }
}

final class Controls extends Component {
  final void Function(Event) onIncrement;
  final void Function(Event) onDecrement;
  
  Controls({
    required this.onIncrement,
    required this.onDecrement,
  });

  @override
  Morphic render() {
    return Div().classes("controls")([
      Button().onClick(onDecrement)(['-']),
      Button().onClick(onIncrement)(['+']),
    ]);
  }
}

This pattern keeps data flow explicit and unidirectional:

  • Parent owns the state
  • Child receives callbacks as constructor parameters
  • Child calls callbacks when events occur
  • Parent handles the event and updates state

Passing Custom Data

To pass custom data with events, use closures or helper methods:

final class ItemList extends Component {
  List<String> items = ['Apple', 'Banana', 'Cherry'];
  
  // Helper method that creates a closure
  void removeItem(String item) {
    morph(() => items.remove(item));
  }
  
  List<Morphic> get itemElements =>
    items.map((item) => Li()([
      Span()([item]),
      Button().onClick((_) => removeItem(item))(['Remove']),
    ])).toList();

  @override
  Morphic render() {
    return Ul()([itemElements]);
  }
}

The closure (_) => removeItem(item) captures the item from the loop and passes it to the handler.

Alternative Pattern: Event Target Data

final class DataList extends Component {
  void handleClick(Event e) {
    final target = e.currentTarget as HTMLElement;
    final itemId = target.getAttribute('data-id');
    
    morph(() {
      print('Clicked item: $itemId');
    });
  }
  
  List<Morphic> get items =>
    ['1', '2', '3'].map((id) => 
      Button()
        .onClick(handleClick)
        .data('id', id)
        (['Item $id'])
    ).toList();

  @override
  Morphic render() {
    return Div()([items]);
  }
}

Event Delegation

Event delegation can be achieved by handling events on a parent element:

final class TodoList extends Component {
  List<Todo> todos = [];
  
  void handleListClick(Event e) {
    final target = e.target as HTMLElement;
    
    // Check if clicked element is a delete button
    if (target.classList.contains('delete-btn')) {
      final todoId = target.getAttribute('data-id');
      if (todoId != null) {
        morph(() => todos.removeWhere((t) => t.id == todoId));
      }
    }
    
    // Check if clicked element is a checkbox
    if (target.getAttribute('type') == 'checkbox') {
      final todoId = target.getAttribute('data-id');
      if (todoId != null) {
        final todo = todos.firstWhere((t) => t.id == todoId);
        morph(() => todo.completed = !todo.completed);
      }
    }
  }
  
  List<Morphic> get todoElements =>
    todos.map((todo) => Li()([
      Input()
        .type(InputType.checkbox)
        .checked(todo.completed)
        .data('id', todo.id)
        ([]),
      Span()([todo.text]),
      Button()
        .classes('delete-btn')
        .data('id', todo.id)
        (['×']),
    ])).toList();

  @override
  Morphic render() {
    // Single handler on parent
    return Ul().onClick(handleListClick)([todoElements]);
  }
}

Event delegation can improve performance with large lists, but in most cases individual handlers are clearer and sufficient.

Available Event Handlers

Pulsar provides a comprehensive set of event handlers covering all standard DOM events. All handlers follow the same signature: void Function(Event).

Mouse Events

  • onClick — Element is clicked
  • onDoubleClick — Element is double-clicked
  • onMouseDown — Mouse button pressed on element
  • onMouseUp — Mouse button released on element
  • onMouseEnter — Mouse enters element (no bubble)
  • onMouseLeave — Mouse leaves element (no bubble)
  • onMouseMove — Mouse moves over element
  • onMouseOver — Mouse enters element or child
  • onMouseOut — Mouse leaves element or child
  • onContextMenu — Right-click context menu

Form Events

  • onInput — Input value changes (use without morph for text)
  • onChange — Input loses focus after value change
  • onSubmit — Form is submitted
  • onFocus — Element receives focus
  • onBlur — Element loses focus
  • onFocusIn — Element or child receives focus (bubbles)
  • onFocusOut — Element or child loses focus (bubbles)
  • onReset — Form is reset
  • onSelect — Text is selected in input

Keyboard Events

  • onKeyDown — Key is pressed down
  • onKeyUp — Key is released
  • onKeyPress — Key is pressed (deprecated, use onKeyDown)

Drag & Drop Events

  • onDrag — Element is being dragged
  • onDragStart — Drag operation starts
  • onDragEnd — Drag operation ends
  • onDragEnter — Dragged element enters drop target
  • onDragLeave — Dragged element leaves drop target
  • onDragOver — Dragged element is over drop target
  • onDrop — Element is dropped on target

Clipboard Events

  • onCopy — Content is copied
  • onCut — Content is cut
  • onPaste — Content is pasted

Media Events

  • onPlay — Media starts playing
  • onPause — Media is paused
  • onEnded — Media playback ended
  • onTimeUpdate — Media playback time updated
  • onVolumeChange — Media volume changed
  • onLoadedData — Media data is loaded
  • onLoadedMetadata — Media metadata is loaded
  • onCanPlay — Media can start playing
  • onCanPlayThrough — Media can play without buffering

Scroll Events

  • onScroll — Element is scrolled
  • onWheel — Mouse wheel is used

Touch Events (Mobile)

  • onTouchStart — Touch point is placed on surface
  • onTouchMove — Touch point moves along surface
  • onTouchEnd — Touch point is removed from surface
  • onTouchCancel — Touch is interrupted

Animation & Transition Events

  • onAnimationStart — CSS animation starts
  • onAnimationEnd — CSS animation ends
  • onAnimationIteration — CSS animation repeats
  • onTransitionEnd — CSS transition completes

Pointer Events

  • onPointerDown — Pointer becomes active
  • onPointerUp — Pointer is released
  • onPointerMove — Pointer moves
  • onPointerEnter — Pointer enters element
  • onPointerLeave — Pointer leaves element
  • onPointerCancel — Pointer event is cancelled

Pointer Events: Pointer events are a modern unified API for mouse, touch, and pen input. They are recommended over separate mouse and touch events for new applications.

Custom Events

For events not covered by the standard handlers, use the generic on() method:

final class CustomEventExample extends Component {
  void handleCustom(Event e) {
    print('Custom event fired');
  }

  @override
  Morphic render() {
    return Div()
      .on('customevent', handleCustom)
      .on('myCustomEvent', handleCustom)
      (['Custom events']);
  }
}

The on() method automatically normalizes event names to the correct format.

The Event Mental Model

An event represents something that happened in the UI. It does not carry business logic or side effects by itself.

  • Events originate from user interaction
  • They are delivered to a component method
  • The component decides what to do
  • State changes happen explicitly via morph()

This keeps the control flow obvious: events go in, state mutates, render is re-invoked, and the UI morphs accordingly.

//  GOOD - Clear flow
void handleClick(Event e) {
  // 1. Event arrives
  // 2. Decide what to do
  if (shouldUpdate()) {
    // 3. Mutate state via morph
    morph(() => count++);
  }
  // 4. render() runs, UI updates
}

//  BAD - Hidden logic
void handleClick(Event e) {
  updateGlobalState();  // Where does this mutation happen?
  triggerEffect();      // What does this do?
  // State changed but how?
}

Best Practices

  • Name handlers by intent, not by event type — Use handleSubmit, not handleClick.
  • Keep event handlers focused — One handler, one responsibility.
  • Use onInput without morph for text inputs — Prevents losing focus.
  • Pass callbacks for parent-child communication — Explicit data flow.
  • Use closures to capture data — Clean way to pass item-specific data.
  • Call preventDefault() when needed — Forms, links, etc.

Anti-Patterns

  • ❌ Calling handlers instead of passing references: onClick(handleClick())
  • ❌ Using morph() in onInput handlers for text inputs
  • ❌ Creating anonymous functions everywhere by default
  • ❌ Mutating state outside of morph()
  • ❌ Embedding business logic inside the event object
  • ❌ Using events for non-user-initiated actions

Advanced: Event Control

final class FormWithLink extends Component {
  void handleFormSubmit(Event e) {
    e.preventDefault();  // Don't reload page
    
    morph(() {
      // Handle form submission
    });
  }
  
  void handleLinkClick(Event e) {
    e.preventDefault();  // Don't navigate
    e.stopPropagation(); // Don't bubble up
    
    // Handle custom navigation
    navigateTo('/custom-route');
  }
  
  void handleOuterClick(Event e) {
    print('Outer clicked');
  }

  @override
  Morphic render() {
    return Div().onClick(handleOuterClick)([
      Form().onSubmit(handleFormSubmit)([
        Input().name('email')([]),
        Button().type(ButtonType.submit)(['Submit']),
      ]),
      A()
        .href('/page')
        .onClick(handleLinkClick)
        (['Custom Link']),
    ]);
  }
}

Key principle: Events express user intent. Components decide behavior. State changes happen explicitly via morph(). Nothing happens implicitly.