JavaScript Component Patterns

Copy Markdown View Source

This guide covers common patterns, best practices, and solutions for building SaladUI JavaScript components.

For the lifecycle rules behind the setup/teardown pairing used throughout this guide, see Component Lifecycle.

Table of Contents

  1. Component Structure Patterns
  2. State Management Patterns
  3. Event Handling Patterns
  4. Accessibility Patterns
  5. Integration Patterns
  6. Common Component Types

Component Structure Patterns

Basic Component Template

import Component from "../core/component";
import SaladUI from "../index";

class MyComponent extends Component {
  constructor(el, hookContext) {
    super(el, {
      hookContext,
      initialState: "idle",
      ignoreItems: true  // Exclude data-part="*-item" from queryParts
    });

    // Cache frequently accessed parts
    this.trigger = this.getPart("trigger");
    this.content = this.getPart("content");

    // Component-specific initialization
    this.value = null;
  }

  getComponentConfig() {
    return {
      stateMachine: this.getStateMachineConfig(),
      events: this.getEventConfig(),
      hiddenConfig: this.getHiddenConfig(),
      ariaConfig: this.getAriaConfig()
    };
  }

  getStateMachineConfig() {
    return {
      idle: {
        enter: "onIdleEnter",
        transitions: { activate: "active" }
      },
      active: {
        enter: "onActiveEnter",
        exit: "onActiveExit",
        transitions: { deactivate: "idle" }
      }
    };
  }

  getEventConfig() {
    return {
      idle: {
        mouseMap: {
          trigger: { click: "activate" }
        }
      },
      active: {
        keyMap: {
          Escape: "deactivate"
        }
      }
    };
  }

  getHiddenConfig() {
    return {
      idle: { content: true },
      active: { content: false }
    };
  }

  getAriaConfig() {
    return {
      trigger: {
        all: { role: "button" },
        active: { expanded: "true" },
        idle: { expanded: "false" }
      }
    };
  }

  // State handlers
  onIdleEnter() {
    this.cleanup();
  }

  onActiveEnter() {
    this.setup();
    this.pushEvent("activated");
  }

  onActiveExit() {
    this.cleanup();
  }

  // Helper methods
  setup() {
    // Setup logic
  }

  cleanup() {
    // Cleanup logic
  }

  beforeDestroy() {
    this.cleanup();
    // Additional cleanup
  }
}

SaladUI.register("my-component", MyComponent);
export default MyComponent;

Component with External Dependencies

import Component from "../core/component";
import SaladUI from "../index";
import FocusTrap from "../core/focus-trap";
import ClickOutsideMonitor from "../core/click-outside";

class ModalComponent extends Component {
  constructor(el, hookContext) {
    super(el, { hookContext, initialState: "closed" });

    this.contentPanel = this.getPart("content-panel");
    this.config.preventDefaultKeys = ["Escape"];
  }

  getComponentConfig() {
    return {
      stateMachine: {
        closed: {
          enter: "onClosedEnter",
          transitions: { open: "open" }
        },
        open: {
          enter: "onOpenEnter",
          transitions: { close: "closed" }
        }
      },
      events: {
        open: {
          keyMap: { Escape: "close" }
        }
      },
      hiddenConfig: {
        closed: { content: true },
        open: { content: false }
      }
    };
  }

  setupComponentEvents() {
    super.setupComponentEvents();

    // Setup click outside detection
    if (this.options.closeOnOutsideClick) {
      this.clickOutsideMonitor = new ClickOutsideMonitor(
        [this.contentPanel],
        (event) => {
          if (event.target.dataset.part === "overlay") {
            this.transition("close");
          }
        }
      );
    }
  }

  // Pair teardown with setup, not beforeDestroy() — teardownComponentEvents()
  // is guaranteed to run whenever setupComponentEvents() does (including if
  // setupEvents() is ever re-invoked), so it's the only safe place to undo
  // what setup created. See docs/component_lifecycle.md#common-pitfalls.
  teardownComponentEvents() {
    super.teardownComponentEvents();

    this.clickOutsideMonitor?.destroy();
    this.clickOutsideMonitor = null;
  }

  onOpenEnter() {
    // Initialize focus trap
    if (!this.focusTrap) {
      this.focusTrap = new FocusTrap(this.contentPanel);
    }
    this.focusTrap.activate();

    // start()/stop() just pause/resume the monitor created in
    // setupComponentEvents() above — they don't create or destroy it.
    this.clickOutsideMonitor?.start();

    this.pushEvent("open");
  }

  onClosedEnter() {
    this.focusTrap?.deactivate();
    this.clickOutsideMonitor?.stop();
    this.pushEvent("close");
  }

  beforeDestroy() {
    // FocusTrap isn't created in setupComponentEvents(), so its cleanup
    // stays here rather than in teardownComponentEvents().
    this.focusTrap?.destroy();
    this.focusTrap = null;
  }
}

SaladUI.register("modal", ModalComponent);
export default ModalComponent;

State Management Patterns

Binary State (Open/Closed)

getStateMachineConfig() {
  return {
    closed: {
      enter: "onClosedEnter",
      transitions: {
        open: "open",
        toggle: "open"
      }
    },
    open: {
      enter: "onOpenEnter",
      transitions: {
        close: "closed",
        toggle: "closed"
      }
    }
  };
}

Multi-State with Loading

getStateMachineConfig() {
  return {
    idle: {
      transitions: {
        submit: "loading",
        reset: "idle"
      }
    },
    loading: {
      enter: "onLoadingEnter",
      transitions: {
        success: "success",
        error: "error",
        cancel: "idle"
      }
    },
    success: {
      enter: "onSuccessEnter",
      transitions: {
        reset: "idle"
      }
    },
    error: {
      enter: "onErrorEnter",
      transitions: {
        retry: "loading",
        reset: "idle"
      }
    }
  };
}

onLoadingEnter() {
  this.pushEvent("loading");
  // Show loading spinner
}

onSuccessEnter() {
  this.pushEvent("success");
  // Show success message
  setTimeout(() => this.transition("reset"), 2000);
}

onErrorEnter(params) {
  this.pushEvent("error", { message: params.error });
  // Show error message
}

Conditional Transitions

getStateMachineConfig() {
  return {
    editing: {
      transitions: {
        save: (params) => {
          // Validate before transitioning
          if (this.validate(params.data)) {
            return "saved";
          } else {
            return "error";
          }
        },
        cancel: "idle"
      }
    },
    saved: {
      enter: (params) => {
        this.pushEvent("saved", params.data);
      },
      transitions: { edit: "editing" }
    },
    error: {
      enter: (params) => {
        this.showErrors(params.errors);
      },
      transitions: { retry: "editing" }
    }
  };
}

validate(data) {
  // Validation logic
  return data && data.value;
}

State with History

constructor(el, hookContext) {
  super(el, { hookContext });
  this.stateHistory = [];
}

onStateChanged(prevState, nextState) {
  this.stateHistory.push({
    from: prevState,
    to: nextState,
    timestamp: Date.now()
  });

  // Keep last 10 transitions
  if (this.stateHistory.length > 10) {
    this.stateHistory.shift();
  }

  return super.onStateChanged(prevState, nextState);
}

handleCommand(command, params) {
  if (command === "undo") {
    const lastTransition = this.stateHistory[this.stateHistory.length - 2];
    if (lastTransition) {
      return this.transition("revert", { toState: lastTransition.from });
    }
  }
  return super.handleCommand(command, params);
}

Event Handling Patterns

Mouse Event Patterns

getEventConfig() {
  return {
    idle: {
      mouseMap: {
        trigger: {
          click: "open",
          mouseenter: (event) => {
            this.preload(); // Preload content on hover
          }
        },
        item: {
          click: (event) => {
            const value = event.target.dataset.value;
            this.selectItem(value);
          },
          mouseenter: "highlightItem",
          mouseleave: "unhighlightItem"
        }
      }
    },
    open: {
      mouseMap: {
        overlay: {
          click: "close"
        },
        content: {
          click: (event) => {
            // Prevent closing when clicking content
            event.stopPropagation();
          }
        }
      }
    }
  };
}

highlightItem(event) {
  event.target.classList.add("highlighted");
}

unhighlightItem(event) {
  event.target.classList.remove("highlighted");
}

Keyboard Navigation Pattern

getEventConfig() {
  return {
    open: {
      keyEventTarget: "content",  // Keys are handled on content part
      keyMap: {
        Escape: "close",
        ArrowDown: (event) => {
          event.preventDefault();
          this.navigateNext();
        },
        ArrowUp: (event) => {
          event.preventDefault();
          this.navigatePrev();
        },
        Enter: (event) => {
          event.preventDefault();
          this.selectCurrent();
        },
        Home: (event) => {
          event.preventDefault();
          this.navigateFirst();
        },
        End: (event) => {
          event.preventDefault();
          this.navigateLast();
        },
        " ": "toggleCurrent"  // Space key
      }
    }
  };
}

constructor(el, hookContext) {
  super(el, { hookContext });
  this.currentIndex = 0;
  this.items = [];
}

setupComponentEvents() {
  super.setupComponentEvents();
  this.updateItems();
}

updateItems() {
  this.items = this.getAllParts("item");
}

navigateNext() {
  this.currentIndex = Math.min(this.currentIndex + 1, this.items.length - 1);
  this.focusCurrentItem();
}

navigatePrev() {
  this.currentIndex = Math.max(this.currentIndex - 1, 0);
  this.focusCurrentItem();
}

navigateFirst() {
  this.currentIndex = 0;
  this.focusCurrentItem();
}

navigateLast() {
  this.currentIndex = this.items.length - 1;
  this.focusCurrentItem();
}

focusCurrentItem() {
  const item = this.items[this.currentIndex];
  if (item) {
    item.focus();
    item.scrollIntoView({ block: "nearest" });
  }
}

selectCurrent() {
  const item = this.items[this.currentIndex];
  if (item) {
    const value = item.dataset.value;
    this.selectItem(value);
  }
}

Global Event Handlers (Active in All States)

getEventConfig() {
  return {
    _all: {
      keyMap: {
        "?": () => this.showHelp(),  // Help in any state
        F1: () => this.showHelp()
      }
    },
    idle: {
      mouseMap: {
        trigger: { click: "open" }
      }
    },
    open: {
      keyMap: {
        Escape: "close"
      }
    }
  };
}

Event Debouncing Pattern

constructor(el, hookContext) {
  super(el, { hookContext });
  this.searchTimeout = null;
}

getEventConfig() {
  return {
    open: {
      mouseMap: {
        "search-input": {
          input: (event) => {
            this.debouncedSearch(event.target.value);
          }
        }
      }
    }
  };
}

debouncedSearch(query) {
  clearTimeout(this.searchTimeout);
  this.searchTimeout = setTimeout(() => {
    this.performSearch(query);
  }, 300);
}

performSearch(query) {
  this.pushEvent("search", { query });
}

beforeDestroy() {
  clearTimeout(this.searchTimeout);
}

Accessibility Patterns

Dialog/Modal ARIA

getAriaConfig() {
  return {
    trigger: {
      all: {
        haspopup: "dialog",
        controls: () => this.getPartId("content")
      },
      open: { expanded: "true" },
      closed: { expanded: "false" }
    },
    content: {
      all: {
        role: "dialog",
        modal: "true"
      },
      open: { hidden: "false" },
      closed: { hidden: "true" }
    },
    "content-panel": {
      open: {
        labelledby: () => this.getPartId("title"),
        describedby: () => this.getPartId("description")
      }
    },
    title: {
      all: { role: "heading" }
    }
  };
}
getAriaConfig() {
  return {
    trigger: {
      all: {
        haspopup: "menu",
        controls: () => this.getPartId("content")
      },
      open: { expanded: "true" },
      closed: { expanded: "false" }
    },
    content: {
      all: { role: "menu" }
    },
    item: {
      all: {
        role: "menuitem",
        tabindex: "-1"
      }
    },
    separator: {
      all: { role: "separator" }
    }
  };
}

Select/Listbox ARIA

getAriaConfig() {
  return {
    trigger: {
      all: {
        role: "combobox",
        haspopup: "listbox",
        controls: () => this.getPartId("content")
      },
      open: { expanded: "true" },
      closed: { expanded: "false" }
    },
    content: {
      all: { role: "listbox" }
    },
    item: {
      all: {
        role: "option",
        tabindex: "-1"
      },
      selected: { selected: "true" },
      unselected: { selected: "false" }
    }
  };
}

Tabs ARIA

getAriaConfig() {
  return {
    list: {
      all: { role: "tablist" }
    },
    trigger: {
      all: {
        role: "tab",
        controls: (el) => {
          const value = el.dataset.value;
          return this.getPartId(`content-${value}`);
        }
      },
      active: {
        selected: "true",
        tabindex: "0"
      },
      inactive: {
        selected: "false",
        tabindex: "-1"
      }
    },
    content: {
      all: {
        role: "tabpanel",
        tabindex: "0"
      }
    }
  };
}

Dynamic ARIA Values

getAriaConfig() {
  return {
    slider: {
      all: {
        role: "slider",
        orientation: () => this.options.orientation || "horizontal",
        valuemin: () => this.min.toString(),
        valuemax: () => this.max.toString(),
        valuenow: () => this.value.toString(),
        valuetext: () => this.formatValue(this.value)
      }
    }
  };
}

formatValue(value) {
  if (this.options.format === "percentage") {
    return `${value}%`;
  }
  return value.toString();
}

Integration Patterns

Server Command Handling

handleCommand(command, params = {}) {
  switch (command) {
    case "open":
      return this.transition("open", params);

    case "close":
      return this.transition("close", params);

    case "update":
      this.updateData(params.data);
      return true;

    case "reset":
      this.reset();
      return true;

    case "highlight":
      this.highlightItem(params.index);
      return true;

    default:
      // Fallback to state machine transition
      return super.handleCommand(command, params);
  }
}

updateData(data) {
  this.data = data;
  this.render();
}

reset() {
  this.data = null;
  this.currentIndex = 0;
  this.transition("idle");
}

highlightItem(index) {
  const item = this.items[index];
  if (item) {
    item.classList.add("highlighted");
    setTimeout(() => {
      item.classList.remove("highlighted");
    }, 1000);
  }
}

Event Mapping to Server

onOpenEnter() {
  // Send simple event
  this.pushEvent("open");
}

onItemSelected(event) {
  const item = event.target.closest("[data-part='item']");
  const value = item.dataset.value;
  const label = item.textContent.trim();

  // Send event with data
  this.pushEvent("select", {
    value: value,
    label: label,
    index: this.items.indexOf(item),
    timestamp: Date.now()
  });

  this.transition("close");
}

onSearchPerformed(query) {
  // Send search event
  this.pushEvent("search", {
    query: query,
    resultCount: this.results.length
  });
}

Option-Based Behavior

constructor(el, hookContext) {
  super(el, { hookContext });

  // Read options from data-options attribute
  this.closeOnSelect = this.options.closeOnSelect !== false;
  this.clearable = this.options.clearable === true;
  this.searchable = this.options.searchable === true;
  this.multiple = this.options.multiple === true;
}

getComponentConfig() {
  const config = {
    stateMachine: { /* ... */ },
    events: {
      open: {
        mouseMap: {
          item: {
            click: (event) => {
              this.selectItem(event);

              // Only close if option is enabled
              if (this.closeOnSelect && !this.multiple) {
                this.transition("close");
              }
            }
          }
        }
      }
    }
  };

  // Add searchable events if enabled
  if (this.searchable) {
    config.events.open.mouseMap["search-input"] = {
      input: (event) => this.filterItems(event.target.value)
    };
  }

  return config;
}

Common Component Types

Toggle Components (Switch, Checkbox)

class ToggleComponent extends Component {
  constructor(el, hookContext) {
    super(el, { hookContext, initialState: "unchecked" });
    this.input = this.getPart("input");
  }

  getComponentConfig() {
    return {
      stateMachine: {
        unchecked: {
          enter: "onUncheckedEnter",
          transitions: {
            check: "checked",
            toggle: "checked"
          }
        },
        checked: {
          enter: "onCheckedEnter",
          transitions: {
            uncheck: "unchecked",
            toggle: "unchecked"
          }
        }
      },
      events: {
        _all: {
          mouseMap: {
            root: { click: "toggle" }
          },
          keyMap: {
            " ": "toggle",
            Enter: "toggle"
          }
        }
      },
      ariaConfig: {
        root: {
          all: { role: "checkbox" },
          checked: { checked: "true" },
          unchecked: { checked: "false" }
        }
      }
    };
  }

  onCheckedEnter() {
    if (this.input) this.input.checked = true;
    this.pushEvent("checked", { value: true });
  }

  onUncheckedEnter() {
    if (this.input) this.input.checked = false;
    this.pushEvent("checked", { value: false });
  }
}

Overlay Components (Popover, Tooltip, Dropdown)

class OverlayComponent extends Component {
  constructor(el, hookContext) {
    super(el, { hookContext, initialState: "closed" });
    this.contentPanel = this.getPart("content-panel");
    this.trigger = this.getPart("trigger");
  }

  getComponentConfig() {
    return {
      stateMachine: {
        closed: {
          transitions: { open: "open" }
        },
        open: {
          enter: "onOpenEnter",
          exit: "onOpenExit",
          transitions: { close: "closed" }
        }
      },
      events: {
        closed: {
          mouseMap: {
            trigger: { click: "open" }
          }
        },
        open: {
          keyMap: {
            Escape: "close"
          }
        }
      },
      hiddenConfig: {
        closed: { content: true },
        open: { content: false }
      }
    };
  }

  onOpenEnter() {
    this.positionContent();
    this.setupClickOutside();
  }

  onOpenExit() {
    this.teardownClickOutside();
  }

  positionContent() {
    // Position content relative to trigger
    const triggerRect = this.trigger.getBoundingClientRect();
    const contentRect = this.contentPanel.getBoundingClientRect();

    // Simple positioning (enhance with Floating UI, etc.)
    this.contentPanel.style.top = `${triggerRect.bottom + 8}px`;
    this.contentPanel.style.left = `${triggerRect.left}px`;
  }

  setupClickOutside() {
    this.clickOutsideHandler = (event) => {
      if (!this.contentPanel.contains(event.target) &&
          !this.trigger.contains(event.target)) {
        this.transition("close");
      }
    };
    document.addEventListener("click", this.clickOutsideHandler);
  }

  teardownClickOutside() {
    if (this.clickOutsideHandler) {
      document.removeEventListener("click", this.clickOutsideHandler);
      this.clickOutsideHandler = null;
    }
  }

  beforeDestroy() {
    this.teardownClickOutside();
  }
}

Collection Components (Accordion, Tabs)

class AccordionComponent extends Component {
  constructor(el, hookContext) {
    super(el, { hookContext, ignoreItems: false });
    this.items = this.getAllParts("item");
    this.allowMultiple = this.options.allowMultiple !== false;
    this.openItems = new Set();
  }

  getComponentConfig() {
    return {
      stateMachine: {
        idle: {
          transitions: {
            toggle: "idle"  // Stay in idle, handle internally
          }
        }
      },
      events: {
        idle: {
          mouseMap: {
            "item-trigger": {
              click: (event) => {
                const item = event.target.closest("[data-part='item']");
                this.toggleItem(item);
              }
            }
          }
        }
      },
      ariaConfig: {
        "item-trigger": {
          all: { role: "button" }
        },
        "item-content": {
          all: { role: "region" }
        }
      }
    };
  }

  toggleItem(item) {
    const itemId = item.dataset.value;
    const isOpen = this.openItems.has(itemId);

    if (isOpen) {
      this.closeItem(itemId);
    } else {
      if (!this.allowMultiple) {
        // Close all other items
        this.openItems.forEach(id => this.closeItem(id));
      }
      this.openItem(itemId);
    }
  }

  openItem(itemId) {
    this.openItems.add(itemId);
    const item = this.items.find(el => el.dataset.value === itemId);
    if (item) {
      const content = item.querySelector("[data-part='item-content']");
      const trigger = item.querySelector("[data-part='item-trigger']");

      if (content) content.hidden = false;
      if (trigger) trigger.setAttribute("aria-expanded", "true");

      this.pushEvent("item-opened", { value: itemId });
    }
  }

  closeItem(itemId) {
    this.openItems.delete(itemId);
    const item = this.items.find(el => el.dataset.value === itemId);
    if (item) {
      const content = item.querySelector("[data-part='item-content']");
      const trigger = item.querySelector("[data-part='item-trigger']");

      if (content) content.hidden = true;
      if (trigger) trigger.setAttribute("aria-expanded", "false");

      this.pushEvent("item-closed", { value: itemId });
    }
  }
}

These patterns provide a solid foundation for building robust, accessible, and maintainable SaladUI components.