Skip to content

Plugins

Emilio Romero edited this page Jun 3, 2026 · 5 revisions

Create custom plugins for Umbra

Plugins allow you to extend Umbra with custom UI components or behavior.

Interface

export interface Plugin<T extends HTMLElement = HTMLElement> {
  // Optional: Element rendered by the plugin
  el?: T;
  // Required: Render the plugin UI
  render(): void | T;
  // Optional: Called when theme changes
  onThemeChange?: (theme: string) => void;
  // Optional: Called when Umbra is destroyed
  onDestroy?: () => void;
}

Lifecycle

  1. Constructor - Plugin is instantiated with the Umbra instance as host
  2. render() - Called during initialization
  3. onThemeChange() - Called whenever the theme changes (including sync from other tabs)
  4. onDestroy() - Called when umbra.destroy() is invoked

Accessing methods

The host parameter is your Umbra instance. You can call:

class MyPlugin implements Plugin {
  public static readonly pluginId = 'u-<something>'; // Required
  private host: any;

  constructor(host: any) {
    this.host = host;
    // Access: host.theme, host.toggleTheme(), host.getCurrentTheme()
  }

  render(): void | HTMLElement {
    throw new Error("Method not implemented.");
  }
  
  onThemeChange(theme: string): void {
    console.log('Theme changed to:', theme);
  }
}

KeyboardShortcut Plugin

import type { Plugin } from '@emrocode/umbra';

interface KeyboardShortcutOptions {
  key?: string;
  ctrl?: boolean;
  shift?: boolean;
  target?: 'body' | 'input' | 'all';
  cooldown?: number;
}

export class KeyboardShortcut implements Plugin {
  public static readonly pluginId = 'u-keyboard-shortcut';
  private _host: any;
  private options: Required<KeyboardShortcutOptions>;
  private _lastTriggered: number = 0;

  constructor(host: any, options?: KeyboardShortcutOptions) {
    this._host = host;
    this.options = {
      key: options?.key ?? 'd',
      ctrl: options?.ctrl ?? false,
      shift: options?.shift ?? false,
      target: options?.target ?? 'body',
      cooldown: options?.cooldown ?? 300,
    };
  }

  private handleKeyDown = (e: KeyboardEvent) => {
    if (this.options.target === 'body' && this.isTyping(e)) return;
    if (this.options.target === 'input' && !this.isTyping(e)) return;

    if (this.matches(e)) {
      e.preventDefault();

      const now = Date.now();
      if (now - this._lastTriggered < this.options.cooldown) return;

      this._lastTriggered = now;
      this._host.toggleTheme();
    }
  };

  render(): void {
    document.addEventListener('keydown', this.handleKeyDown);
  }

  onDestroy(): void {
    document.removeEventListener('keydown', this.handleKeyDown);
  }

  private matches(e: KeyboardEvent): boolean {
    return (
      e.key.toLowerCase() === this.options.key.toLowerCase() &&
      (!this.options.ctrl || e.ctrlKey || e.metaKey) &&
      (!this.options.shift || e.shiftKey) &&
      (this.options.ctrl || (!e.ctrlKey && !e.metaKey && !e.altKey))
    );
  }

  private isTyping(e: KeyboardEvent): boolean {
    const target = e.target as HTMLElement;
    const tagName = target.tagName.toLowerCase();
    const isEditable =
      target.isContentEditable || tagName === 'input' || tagName === 'textarea';
    return isEditable;
  }
}

Clone this wiki locally