Modal

Centered dialog with a dimmed backdrop. Closes on Escape and on backdrop click, locks body scroll while open, restores it on close. Built entirely from data-if and a single document-level listener — no portal, no library.

Live preview

Markup

<div data-component="modal-demo">
  <button @click="open">Open dialog</button>

  <div class="modal-backdrop" data-if="visible" @click.self="cancel">
    <div class="modal-dialog" role="dialog" aria-modal="true">
      <header class="modal-header">
        <h3>Delete project?</h3>
        <button class="icon-btn" aria-label="Close" @click="cancel">×</button>
      </header>
      <div class="modal-body">Confirmation message…</div>
      <footer class="modal-footer">
        <button @click="cancel">Cancel</button>
        <button class="btn-primary" @click="confirm">Delete</button>
      </footer>
    </div>
  </div>
</div>

Definition

Micra.define('modal-demo', {
  state: { visible: false, lastAction: '' },

  onCreate() {
    this._onKey = (e) => {
      if (e.key === 'Escape' && this.state.visible) this.cancel()
    }
    document.addEventListener('keydown', this._onKey)
  },

  onDestroy() {
    document.removeEventListener('keydown', this._onKey)
    document.body.style.overflow = ''
  },

  open() {
    this.state.visible = true
    document.body.style.overflow = 'hidden'   // lock scroll
  },

  // close() is the bare close — does NOT touch lastAction so confirm()
  // can stamp 'confirmed' first and then dismiss.
  close() {
    this.state.visible = false
    document.body.style.overflow = ''
  },

  cancel() {
    this.state.lastAction = 'cancelled'
    this.close()
  },

  confirm() {
    this.state.lastAction = 'confirmed'
    this.close()
  },
})

Integration

  • Backdrop click closes. @click.self="close" fires only when the click target is the backdrop itself — clicks inside the dialog don't bubble through.
  • Escape closes. Single document-level listener in onCreate; removed in onDestroy so a destroyed instance can't intercept stray Escape presses.
  • Body scroll lock. document.body.style.overflow = 'hidden' while open. Always reset in close AND onDestroy so the page can't end up frozen if the instance is torn down with the modal open.
  • Slot content. Any markup inside the dialog renders normally — bind values with data-text or pass props via data-* + this.prop().
  • Bus events. Emit Micra.emit('modal:open', payload) from another component to drive the modal — combine with this.on() in onCreate for cross-component control.

Pitfalls

  • Don't use data-show for the backdrop — keyboard focus would still reach the hidden buttons inside. data-if removes them from the tab order entirely.
  • Don't attach Escape with addEventListener inside open() without removing it on close — listeners stack. The pattern above attaches once in onCreate and gates on visible.
  • Don't put a setTimeout-driven animation that mutates state from outside Micra's render cycle. Use CSS transitions on a static class, or animate via data-class bound to state.