Skip to content

UI Events Helper

The ctx.ui.events object provides reactive subscriptions to Lumiverse UI state (keyboard, drawer, settings) and a generic DOM event delegation helper. This is particularly useful for tracking mobile keyboard dimensions and for binding interactions on extension-injected or mounted DOM elements without leaking global event listeners.

Keyboard State

Access the virtual keyboard presence and safe-area inset (especially on iOS PWA or Android Chrome where the window resizing or visual viewport varies).

export interface SpindleUIKeyboardState {
  /** True when the host believes a virtual keyboard is currently visible. */
  visible: boolean;
  /** Safe bottom inset in CSS pixels that keeps content above the keyboard. */
  insetBottom: number;
  /** Current visual viewport width in CSS pixels. */
  viewportWidth: number;
  /** Current visual viewport height in CSS pixels. */
  viewportHeight: number;
}
export function setup(ctx: SpindleFrontendContext) {
  // Read synchronously
  const state = ctx.ui.events.getKeyboardState()

  // Subscribe to changes (returns an unsubscribe function)
  const unsub = ctx.ui.events.onKeyboardChange((newState) => {
    if (newState.visible) {
      console.log(`Keyboard opened. Safe inset bottom: ${newState.insetBottom}px`)
    }
  })
}

Drawer State

Track when the side portrait drawer is opened, closed, or switched to a different tab.

export interface SpindleUIDrawerState {
  open: boolean;
  tabId: string | null;
}
export function setup(ctx: SpindleFrontendContext) {
  const unsub = ctx.ui.events.onDrawerChange(({ open, tabId }) => {
    if (open && tabId === 'my-extension-tab') {
      console.log('User opened my drawer tab!')
    }
  })
}

Settings State

Track when the settings modal is opened, closed, or switched to a different view.

export interface SpindleUISettingsState {
  open: boolean;
  view: string;
}
export function setup(ctx: SpindleFrontendContext) {
  const unsub = ctx.ui.events.onSettingsChange(({ open, view }) => {
    if (open && view === 'my-extension-settings') {
      console.log('User opened my extension settings panel!')
    }
  })
}

DOM Action Delegation

bindActionHandlers is a helper for binding generic interaction events (click, pointer down, etc.) onto extension-owned DOM, such as elements injected via ctx.dom.inject(), mounted via ctx.ui.mount(), or returned by ctx.ui.showModal().

Instead of adding dozens of individual .addEventListener() calls, you can define a dictionary of action handlers. The helper binds a single delegating event listener to the root container and maps events back to your callbacks.

Usage

export function setup(ctx: SpindleFrontendContext) {
  // 1. Create your extension-owned DOM
  const root = ctx.ui.mount('sidebar')
  root.innerHTML = `
    <div id="btn-approve" class="btn">Approve</div>
    <div id="btn-reject" class="btn">Reject</div>
  `

  // 2. Bind action handlers
  // The dictionary keys map to the "id" attribute of descendants by default.
  const unbind = ctx.ui.events.bindActionHandlers(root, {
    'btn-approve': (detail) => {
      console.log('Approved!', detail.element, detail.originalEvent)
    },
    'btn-reject': () => {
      console.log('Rejected!')
    }
  })
}

Advanced Binding

You can override the matching attribute and listen to different pointer events.

const root = ctx.dom.inject('body', `
  <button data-action="swipe-left">Left</button>
  <button data-action="swipe-right">Right</button>
`)

const unbind = ctx.ui.events.bindActionHandlers(root, {
  'swipe-left': (detail) => { /* ... */ },
  'swipe-right': (detail) => { /* ... */ }
}, {
  attribute: 'data-action',
  events: ['pointerdown', 'pointerup']
})

Note: The target root element must be owned by the calling extension (e.g. marked with data-spindle-extension-root internally). The helper will throw an error if you attempt to bind action listeners to system UI or another extension's DOM.