UI Core Behaviors
Eight framework-agnostic headless UI behaviors — Dialog, Disclosure, Form, List Selection, Roving Focus, Keyboard Shortcuts, Undo/Redo, and Drag-and-Drop.
UI Core Behaviors
@web-loom/ui-core provides eight atomic, headless UI interaction behaviors. Each behavior manages interaction logic — state, validation, keyboard navigation, history — with no assumptions about styling, DOM structure, or rendering framework. They are the building blocks that @web-loom/ui-patterns composes into higher-level patterns like wizards, modals, and command palettes.
8 behaviors available: Dialog, Disclosure, Form, List Selection, Roving Focus, Keyboard Shortcuts, Undo/Redo Stack, Drag-and-Drop.
Installation
npm install @web-loom/ui-coreThe Common Behavior Interface
Every behavior follows the same contract, making them interchangeable and easy to integrate uniformly.
interface Behavior<TState, TActions> {
getState(): TState;
subscribe(listener: (state: TState) => void): () => void;
actions: TActions;
destroy(): void;
}getState()— synchronous snapshot of current state. Safe to call anytime.subscribe(fn)— called on every state change. Returns an unsubscribe function — always call it on teardown.actions— the only way to mutate state. Never modifygetState()directly.destroy()— cleans up internal store subscriptions, event listeners, and timers. Always call this when the enclosing component unmounts.
const behavior = createSomeBehavior({ ... });
const unsubscribe = behavior.subscribe((state) => render(state));
behavior.actions.doSomething();
// Teardown
unsubscribe();
behavior.destroy();Dialog Behavior
Manages open/close state and content payload for modal dialogs, drawers, popovers, or any toggled overlay.
import { createDialogBehavior } from '@web-loom/ui-core';
const dialog = createDialogBehavior({
id: 'settings-dialog',
onOpen: (content) => console.log('opened with', content),
onClose: () => console.log('closed'),
});
dialog.actions.open({ tab: 'notifications' });
console.log(dialog.getState().isOpen); // true
console.log(dialog.getState().content); // { tab: 'notifications' }
dialog.actions.close();
dialog.actions.toggle({ tab: 'profile' }); // opens with contentOptions
id?: string— optional identifier, stored in stateonOpen?: (content: any) => void— fired each time the dialog opensonClose?: () => void— fired each time the dialog closes
State
isOpen: booleancontent: any— the value passed to the lastopen()callid: string | null
Actions
open(content?: any)— open the dialog, optionally passing contentclose()— close the dialogtoggle(content?: any)— if closed, opens with content; if open, closes
React Example
import { createDialogBehavior } from '@web-loom/ui-core';
import { useState, useEffect, useMemo } from 'react';
function useBehavior<S>(b: { getState(): S; subscribe(fn: (s: S) => void): () => void }) {
const [state, setState] = useState(() => b.getState());
useEffect(() => b.subscribe(setState), [b]);
return state;
}
const settingsDialog = createDialogBehavior({ id: 'settings' });
export function SettingsButton() {
const { isOpen, content } = useBehavior(settingsDialog);
return (
<>
<button onClick={() => settingsDialog.actions.open({ tab: 'general' })}>
Settings
</button>
{isOpen && (
<div role="dialog" aria-modal="true">
<SettingsPanel initialTab={content?.tab} />
<button onClick={() => settingsDialog.actions.close()}>Close</button>
</div>
)}
</>
);
}Disclosure Behavior
Controls expandable/collapsible sections — accordions, dropdowns, detail panels, or any show/hide toggle.
import { createDisclosureBehavior } from '@web-loom/ui-core';
const faqItem = createDisclosureBehavior({
id: 'faq-1',
initialExpanded: false,
onExpand: () => analytics.track('faq_expanded'),
onCollapse: () => {},
});
faqItem.actions.toggle();
console.log(faqItem.getState().isExpanded); // true
faqItem.actions.collapse();
faqItem.actions.expand();Options
id?: string— optional identifierinitialExpanded?: boolean(default:false)onExpand?: () => void— fired when transitioning to expandedonCollapse?: () => void— fired when transitioning to collapsed
State
isExpanded: booleanid: string | null
Actions
expand()— setisExpandedtotruecollapse()— setisExpandedtofalsetoggle()— flip the expanded state
Accordion Example
// Create one disclosure per FAQ item
const faqs = faqData.map((item) =>
createDisclosureBehavior({ id: item.id }),
);
// Optionally enforce single-open (accordion behavior)
faqs.forEach((faq, i) => {
faqs[i] = createDisclosureBehavior({
id: faqData[i].id,
onExpand: () => {
// Collapse all others when one opens
faqs.forEach((other, j) => {
if (j !== i) other.actions.collapse();
});
},
});
});Form Behavior
Manages form field values, validation (sync and async), touched/dirty tracking, and submission lifecycle.
import { createFormBehavior } from '@web-loom/ui-core';
const loginForm = createFormBehavior({
initialValues: { email: '', password: '' },
validateOnChange: false,
validateOnBlur: true,
fields: {
email: {
validators: [
(v) => (!v ? 'Required' : null),
(v) => (!/^[^\s@]+@[^\s@]+$/.test(v) ? 'Invalid email' : null),
],
},
password: {
validators: [(v) => (v.length < 8 ? 'Min 8 characters' : null)],
},
},
onSubmit: async (values) => {
await api.login(values);
},
});Options
initialValues: T— required — field values at mountfields?: Partial<Record<keyof T, FieldConfig>>— per-field validator arraysvalidateOnChange?: boolean(default:false) — validate every keystrokevalidateOnBlur?: boolean(default:true) — validate when a field loses focusonSubmit?: (values: T) => void | Promise<void>— called after successful validationonValuesChange?: (values: T) => void— fires on every value change
FieldConfig
interface FieldConfig {
validators?: Array<(value: any) => string | null | undefined | Promise<string | null | undefined>>;
}Validators receive the field's current value and return an error string, or null/undefined if valid. Both sync and async validators are supported.
State
values: T— live field valueserrors: Partial<Record<keyof T, string>>— all active errors (merged from validators + manual)manualErrors: Partial<Record<keyof T, string>>— errors set viasetFieldError(e.g. from server responses)touched: Partial<Record<keyof T, boolean>>— fields the user has focused and leftdirty: Partial<Record<keyof T, boolean>>— fields changed frominitialValuesisValid: boolean—truewhenerrorsis emptyisValidating: boolean—truewhile async validators are runningisSubmitting: boolean—truebetweensubmitForm()call andonSubmitresolutionsubmitCount: number— increments on every submit attempt
Actions
setFieldValue(field, value)— update a field's value; marks it dirty; triggers validation ifvalidateOnChangeis setsetFieldTouched(field, touched)— mark a field as touched/untouched; triggers validation ifvalidateOnBluris setsetFieldError(field, error)— inject a manual error (e.g.'Email already taken'from the server)validateField(field): Promise<void>— run validators for a single fieldvalidateForm(): Promise<boolean>— run all validators; returnstrueif validresetForm()— restoreinitialValues, clear all errors, dirty, touched, and submitCountsubmitForm(): Promise<void>— validate the entire form, then callonSubmitif valid
React Example
const form = createFormBehavior({
initialValues: { email: '', password: '' },
validateOnBlur: true,
fields: {
email: { validators: [(v) => (!v ? 'Required' : null)] },
password: { validators: [(v) => (v.length < 8 ? 'Too short' : null)] },
},
onSubmit: async (values) => api.login(values),
});
export function LoginForm() {
const { values, errors, touched, isSubmitting } = useBehavior(form);
return (
<form onSubmit={(e) => { e.preventDefault(); form.actions.submitForm(); }}>
<input
value={values.email}
onChange={(e) => form.actions.setFieldValue('email', e.target.value)}
onBlur={() => form.actions.setFieldTouched('email', true)}
/>
{touched.email && errors.email && <span>{errors.email}</span>}
<input
type="password"
value={values.password}
onChange={(e) => form.actions.setFieldValue('password', e.target.value)}
onBlur={() => form.actions.setFieldTouched('password', true)}
/>
{touched.password && errors.password && <span>{errors.password}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
);
}Handling Server Errors
try {
await form.actions.submitForm();
} catch (err) {
// Inject server-side validation errors back into the form
form.actions.setFieldError('email', 'This email is already registered');
}List Selection Behavior
Manages single, multi, or range selection over an array of item IDs, with shift-click range support.
import { createListSelection } from '@web-loom/ui-core';
const selection = createListSelection({
items: ['a', 'b', 'c', 'd', 'e'],
mode: 'multi',
initialSelectedIds: ['a'],
onSelectionChange: (ids) => console.log('selected:', ids),
});
selection.actions.select('b');
selection.actions.toggleSelection('c'); // selects
selection.actions.toggleSelection('c'); // deselects
selection.actions.selectRange('b', 'd'); // selects b, c, d
selection.actions.clearSelection();
selection.actions.selectAll();Options
items?: string[]— the full list of available item IDs (default:[])initialSelectedIds?: string[]— pre-selected IDs (default:[])mode?: 'single' | 'multi' | 'range'(default:'single')onSelectionChange?: (selectedIds: string[]) => void
Selection Modes
'single'— callingselect()replaces any existing selection'multi'— eachselect()adds to the selection independently'range'—selectRange()selects all items between two IDs inclusive; also supports multi-select
State
selectedIds: string[]— currently selected item IDslastSelectedId: string | null— most recently selected ID (useful for range anchoring)mode: SelectionModeitems: string[]
Actions
select(id: string)— select an item (replaces in single mode, adds in multi/range)deselect(id: string)— remove an item from selectiontoggleSelection(id: string)— select if not selected, deselect if already selectedselectRange(startId, endId)— select all items between two IDs by their position initemsclearSelection()— deselect allselectAll()— select all items initems
Example — File Browser
const filePicker = createListSelection({
items: files.map((f) => f.id),
mode: 'range',
onSelectionChange: (ids) => setSelectedFiles(ids),
});
// Single click: select
fileEl.addEventListener('click', () =>
filePicker.actions.select(file.id),
);
// Shift+click: extend range
fileEl.addEventListener('click', (e) => {
if (e.shiftKey && filePicker.getState().lastSelectedId) {
filePicker.actions.selectRange(filePicker.getState().lastSelectedId!, file.id);
} else {
filePicker.actions.select(file.id);
}
});
// Ctrl/Cmd+click: toggle
fileEl.addEventListener('click', (e) => {
if (e.ctrlKey || e.metaKey) filePicker.actions.toggleSelection(file.id);
});Roving Focus Behavior
Implements the roving tabindex pattern for ARIA-compliant keyboard navigation in composite widgets — menus, toolbars, tab lists, radio groups, grids.
import { createRovingFocus } from '@web-loom/ui-core';
const toolbar = createRovingFocus({
items: ['bold', 'italic', 'underline', 'link'],
orientation: 'horizontal',
wrap: true,
onFocusChange: (index, itemId, previousIndex) => {
document.getElementById(itemId)?.focus();
},
});
// Move with arrow keys
toolbar.actions.moveNext();
toolbar.actions.movePrevious();
toolbar.actions.moveFirst();
toolbar.actions.moveLast();
toolbar.actions.moveTo(2);Options
items?: string[]— item IDs in DOM order (default:[])initialIndex?: number— initially focused index (default:0)orientation?: 'horizontal' | 'vertical'(default:'vertical')wrap?: boolean— wrap at first/last item (default:true)onFocusChange?: (index: number, itemId: string, previousIndex: number) => void
State
currentIndex: number— focused item indexpreviousIndex: number— last focused indexitems: string[]orientation: 'horizontal' | 'vertical'wrap: boolean
Actions
moveNext()— advance focus by one (wraps ifwrapis true)movePrevious()— retreat focus by one (wraps ifwrapis true)moveFirst()— jump to index0moveLast()— jump to the last indexmoveTo(index: number)— jump to a specific indexsetItems(items: string[])— replace the item list (resetscurrentIndexto0)
ARIA Toolbar Example
const toolbar = createRovingFocus({
items: buttonIds,
orientation: 'horizontal',
onFocusChange: (_, id) => document.getElementById(id)?.focus(),
});
export function Toolbar({ buttons }: { buttons: ToolbarButton[] }) {
const { currentIndex } = useBehavior(toolbar);
return (
<div
role="toolbar"
aria-orientation="horizontal"
onKeyDown={(e) => {
if (e.key === 'ArrowRight') { e.preventDefault(); toolbar.actions.moveNext(); }
if (e.key === 'ArrowLeft') { e.preventDefault(); toolbar.actions.movePrevious(); }
if (e.key === 'Home') { e.preventDefault(); toolbar.actions.moveFirst(); }
if (e.key === 'End') { e.preventDefault(); toolbar.actions.moveLast(); }
}}
>
{buttons.map((btn, i) => (
<button
key={btn.id}
id={btn.id}
tabIndex={i === currentIndex ? 0 : -1}
onClick={() => toolbar.actions.moveTo(i)}
>
{btn.label}
</button>
))}
</div>
);
}Keyboard Shortcuts Behavior
Registers and dispatches keyboard shortcuts with platform normalization (Cmd on macOS, Ctrl on Windows/Linux) and scope management.
import { createKeyboardShortcuts } from '@web-loom/ui-core';
const shortcuts = createKeyboardShortcuts({
scope: 'global',
onShortcutExecuted: (key) => console.log('executed:', key),
});
shortcuts.actions.registerShortcut({
key: 'Ctrl+K',
description: 'Open command palette',
handler: () => commandPalette.actions.open(),
preventDefault: true,
});
shortcuts.actions.registerShortcut({
key: 'Ctrl+Z',
description: 'Undo',
handler: () => editor.actions.undo(),
preventDefault: true,
});
shortcuts.actions.registerShortcut({
key: 'Ctrl+Shift+Z',
description: 'Redo',
handler: () => editor.actions.redo(),
preventDefault: true,
});KeyboardShortcut Object
key: string— combo string e.g.'Ctrl+K','Cmd+Shift+P','Alt+F4'handler: () => void— action to executedescription?: string— human-readable description (useful for a shortcut cheat sheet)preventDefault?: boolean— callevent.preventDefault()(default:false)scope?: 'global' | 'scoped'— whether to respond when a focused input is active (default:'global')
Options
scope?: 'global' | 'scoped'— default scope for new shortcutsonShortcutExecuted?: (key: string) => void— fires after each handler runs
State
shortcuts: Map<string, KeyboardShortcut>— registered shortcuts keyed by combo stringactiveShortcuts: string[]— array of registered combo stringsscope: 'global' | 'scoped'enabled: boolean— master switch
Actions
registerShortcut(shortcut: KeyboardShortcut)— add or replace a shortcutunregisterShortcut(key: string)— remove a shortcutsetScope(scope)— change the default scopeenable()/disable()— master on/off toggle (e.g. disable during modal presentation)clearAllShortcuts()— remove all registered shortcuts
Platform Normalization
Ctrl+K and Cmd+K both resolve to the same shortcut — the behavior normalises Meta (macOS) and Control (Windows/Linux) so a single registration works cross-platform.
Displaying a Shortcut Reference
const { activeShortcuts, shortcuts } = shortcutsBehavior.getState();
const cheatSheet = activeShortcuts
.map((key) => shortcuts.get(key)!)
.filter((s) => s.description)
.map((s) => ({ key: s.key, description: s.description }));Undo/Redo Stack Behavior
Maintains a bounded history of arbitrary state snapshots with undo, redo, and history-jump operations.
import { createUndoRedoStack } from '@web-loom/ui-core';
const history = createUndoRedoStack<EditorState>({
initialState: { content: '', cursor: 0 },
maxLength: 100,
onStateChange: (state) => applyEditorState(state),
});
// Every edit pushes a new snapshot
history.actions.pushState({ content: 'Hello', cursor: 5 });
history.actions.pushState({ content: 'Hello World', cursor: 11 });
console.log(history.getState().canUndo); // true
history.actions.undo(); // back to { content: 'Hello', cursor: 5 }
console.log(history.getState().canRedo); // true
history.actions.redo(); // forward to { content: 'Hello World', cursor: 11 }
history.actions.clearHistory(); // wipe history, keep presentOptions
initialState: T— required — the starting value ofpresentmaxLength?: number— max number of past states to retain (default:50)onStateChange?: (state: T) => void— fires after every undo, redo, or push
State
present: T— current state valuepast: T[]— past states, oldest first; length ≤maxLengthfuture: T[]— states available for redocanUndo: boolean—past.length > 0canRedo: boolean—future.length > 0maxLength: number
Actions
pushState(state: T)— push a new present; clearsfuture; trimspastwhen overmaxLengthundo()— movepresent→future, top ofpast→presentredo()— movepresent→past, top offuture→presentjumpToState(index: number)— jump to a specific position in the combined history arraysetMaxLength(length: number)— change limit, trimming oldest entries if neededclearHistory()— emptypastandfuture, keeppresent
Text Editor Integration
const editorHistory = createUndoRedoStack({
initialState: { content: '', selectionStart: 0, selectionEnd: 0 },
maxLength: 200,
onStateChange: ({ content, selectionStart, selectionEnd }) => {
textarea.value = content;
textarea.setSelectionRange(selectionStart, selectionEnd);
},
});
// Push snapshot on every meaningful edit (debounced)
let debounce: ReturnType<typeof setTimeout>;
textarea.addEventListener('input', () => {
clearTimeout(debounce);
debounce = setTimeout(() => {
editorHistory.actions.pushState({
content: textarea.value,
selectionStart: textarea.selectionStart,
selectionEnd: textarea.selectionEnd,
});
}, 300);
});
// Ctrl+Z / Ctrl+Y
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) editorHistory.actions.redo();
else editorHistory.actions.undo();
}
});Drag-and-Drop Behavior
Manages drag-and-drop interaction state — what is being dragged, where it can be dropped, and optional validation — without relying on native DragEvent APIs. Works equally with pointer events, touch events, or native drag-and-drop.
import { createDragDropBehavior } from '@web-loom/ui-core';
const dnd = createDragDropBehavior({
onDragStart: (itemId, data) => console.log('dragging', itemId, data),
onDragEnd: (itemId) => console.log('drag ended for', itemId),
onDrop: (draggedItem, dropTarget, data) => {
moveItemToZone(draggedItem, dropTarget, data);
},
validateDrop: (draggedItem, dropTarget) => {
// Prevent dropping items on themselves
return draggedItem !== dropTarget;
},
});
// Register valid drop zones
dnd.actions.registerDropZone('zone-a');
dnd.actions.registerDropZone('zone-b');
// Begin drag
dnd.actions.startDrag('card-1', { priority: 'high' });
// Update hover target while dragging
dnd.actions.setDragOver('zone-a');
// Complete drop
dnd.actions.drop('zone-a');
// Or cancel
dnd.actions.endDrag();Options
onDragStart?: (itemId: string, data: any) => voidonDragEnd?: (itemId: string) => void— called on both successful drop and cancelled dragonDrop?: (draggedItem: string, dropTarget: string, data: any) => voidvalidateDrop?: (draggedItem: string, dropTarget: string) => boolean— returnfalseto reject the drop
State
draggedItem: string | null— ID of the item currently being draggeddragData: any— arbitrary data payload fromstartDragisDragging: booleandropTarget: string | null— last confirmed valid drop targetdragOverZone: string | null— drop zone currently being hovereddropZones: string[]— all registered zone IDs
Actions
startDrag(itemId, data?)— begin a drag; setsdraggedItemanddragDataendDrag()— cancel or finish a drag without droppingsetDropTarget(targetId: string | null)— set the confirmed drop targetsetDragOver(zoneId: string | null)— update the hovered zone (for hover highlighting)drop(targetId: string)— runvalidateDrop, callonDropif valid, then reset stateregisterDropZone(zoneId: string)— add a zone to the valid zone listunregisterDropZone(zoneId: string)— remove a zone
Kanban Board Example
const kanbanDnd = createDragDropBehavior({
onDrop: (cardId, columnId) => {
viewModel.moveCardCommand.execute({ cardId, columnId });
},
validateDrop: (cardId, columnId) => {
const card = viewModel.getCard(cardId);
return card.status !== columnId; // don't drop in current column
},
});
// Register columns as drop zones
['todo', 'in-progress', 'done'].forEach((col) =>
kanbanDnd.actions.registerDropZone(col),
);
// Subscribe to highlight the active zone
kanbanDnd.subscribe(({ dragOverZone, isDragging }) => {
columns.forEach((col) => {
col.el.classList.toggle('drag-over', isDragging && col.id === dragOverZone);
});
});Composition
Behaviors compose naturally — @web-loom/ui-patterns is built entirely from these atoms. You can do the same.
import {
createDialogBehavior,
createFormBehavior,
createListSelection,
} from '@web-loom/ui-core';
// A "command editor" that is: a dialog + a form + a list picker
function createCommandEditor() {
const dialog = createDialogBehavior({ id: 'command-editor' });
const form = createFormBehavior({ initialValues: { name: '', type: '' } });
const typePicker = createListSelection({ items: ['action', 'query', 'event'], mode: 'single' });
// Sync picker selection into form field
typePicker.subscribe(({ selectedIds }) => {
form.actions.setFieldValue('type', selectedIds[0] ?? '');
});
return {
open: (cmd: Command) => { form.actions.resetForm(); dialog.actions.open(cmd); },
close: () => dialog.actions.close(),
submit: () => form.actions.submitForm(),
dialog,
form,
typePicker,
destroy() {
dialog.destroy();
form.destroy();
typePicker.destroy();
},
};
}Framework Integration
The same subscribe / getState contract works in any framework. Use a single adapter hook per project.
React
import { useState, useEffect } from 'react';
function useBehavior<S>(b: { getState(): S; subscribe(fn: (s: S) => void): () => void }): S {
const [state, setState] = useState(() => b.getState());
useEffect(() => b.subscribe(setState), [b]);
return state;
}
// Usage
const { isOpen, content } = useBehavior(dialog);
const { values, errors, isSubmitting } = useBehavior(form);Vue 3
import { ref, onMounted, onUnmounted, readonly } from 'vue';
function useBehavior<S>(b: { getState(): S; subscribe(fn: (s: S) => void): () => void }) {
const state = ref<S>(b.getState());
let unsub: (() => void) | null = null;
onMounted(() => { unsub = b.subscribe((s) => { state.value = s as S; }); });
onUnmounted(() => { unsub?.(); b.destroy?.(); });
return readonly(state);
}Angular
import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { createDialogBehavior, type DialogState } from '@web-loom/ui-core';
@Injectable({ providedIn: 'root' })
export class SettingsDialogService implements OnDestroy {
private behavior = createDialogBehavior({ id: 'settings' });
private _state$ = new BehaviorSubject<DialogState>(this.behavior.getState());
constructor() {
this.behavior.subscribe((s) => this._state$.next(s));
}
get state$() { return this._state$.asObservable(); }
get actions() { return this.behavior.actions; }
ngOnDestroy() { this.behavior.destroy(); }
}Testing
Behaviors have no framework dependencies and are fully synchronous (except async form validators), making them easy to test with plain Vitest.
import { describe, it, expect, vi } from 'vitest';
import { createFormBehavior } from '@web-loom/ui-core';
describe('createFormBehavior', () => {
it('marks field as dirty on change', () => {
const form = createFormBehavior({
initialValues: { name: '' },
});
form.actions.setFieldValue('name', 'Alice');
expect(form.getState().dirty.name).toBe(true);
expect(form.getState().values.name).toBe('Alice');
form.destroy();
});
it('blocks submit when validation fails', async () => {
const onSubmit = vi.fn();
const form = createFormBehavior({
initialValues: { email: '' },
fields: { email: { validators: [(v) => (!v ? 'Required' : null)] } },
onSubmit,
});
await form.actions.submitForm();
expect(onSubmit).not.toHaveBeenCalled();
expect(form.getState().errors.email).toBe('Required');
form.destroy();
});
it('calls onSubmit when valid', async () => {
const onSubmit = vi.fn();
const form = createFormBehavior({
initialValues: { email: 'alice@example.com' },
onSubmit,
});
await form.actions.submitForm();
expect(onSubmit).toHaveBeenCalledWith({ email: 'alice@example.com' });
form.destroy();
});
});import { describe, it, expect } from 'vitest';
import { createUndoRedoStack } from '@web-loom/ui-core';
describe('createUndoRedoStack', () => {
it('supports undo and redo', () => {
const h = createUndoRedoStack({ initialState: 0 });
h.actions.pushState(1);
h.actions.pushState(2);
expect(h.getState().present).toBe(2);
h.actions.undo();
expect(h.getState().present).toBe(1);
expect(h.getState().canRedo).toBe(true);
h.actions.redo();
expect(h.getState().present).toBe(2);
h.destroy();
});
it('respects maxLength', () => {
const h = createUndoRedoStack({ initialState: 0, maxLength: 3 });
[1, 2, 3, 4].forEach((n) => h.actions.pushState(n));
expect(h.getState().past.length).toBe(3); // oldest entry trimmed
h.destroy();
});
});Best Practices
- Always call
destroy()— every behavior registers event listeners (KeyboardShortcuts),setTimeouttimers (none by default, but subclasses may add them), or store subscriptions. Failing to destroy causes memory leaks. - One behavior per concern — don't put form logic in a disclosure or selection logic in a dialog. Compose multiple small behaviors instead.
- Expose behaviors through ViewModels — don't create behaviors directly inside render functions. Create them once (in a ViewModel, a service, or a module singleton) and pass them down.
- Use
setFieldErrorfor server errors — never validate server responses insidevalidators. CallsetFieldErrorafter the API response so the error appears in the sameerrorsmap as client-side errors. validateOnChangeis expensive for async validators — prefervalidateOnBlurunless the field genuinely needs immediate feedback (e.g. a password strength meter).- Roving focus needs DOM focus — the behavior only updates state; you must call
.focus()on the actual DOM element insideonFocusChange.
See Also
- UI Patterns — higher-level composed patterns built on these behaviors
- Store Core — reactive state layer used internally by every behavior
- Event Bus Core — event system for cross-behavior communication