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
- Component Structure Patterns
- State Management Patterns
- Event Handling Patterns
- Accessibility Patterns
- Integration Patterns
- 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" }
}
};
}Menu ARIA
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.