UI Patterns
Composed UI patterns built on Web Loom UI Core behaviors — Master-Detail, Wizard, Modal, Tabs, Sidebar, Toast, Command Palette, Hub-and-Spoke, Grid Layout, and FAB.
UI Patterns
@web-loom/ui-patterns composes atomic behaviors from @web-loom/ui-core into complete, production-ready UI interaction patterns. Each pattern manages its own reactive state, exposes typed actions, and emits events — with no dependency on any UI framework.
10 patterns available: Master-Detail, Wizard, Modal, Tabbed Interface, Sidebar Shell, Toast Queue, Command Palette, Hub-and-Spoke, Grid Layout, Floating Action Button.
Installation
npm install @web-loom/ui-patternsThe Common Pattern Interface
Every pattern follows the same contract so you can integrate them consistently regardless of which pattern you're using.
interface PatternBehavior<State, Actions> {
getState(): State; // synchronous snapshot
subscribe(listener: (state: State) => void): () => void; // returns unsubscribe fn
actions: Actions; // all available mutations
eventBus?: EventBus; // pattern-specific events
destroy(): void; // cleanup: subscriptions, timers, listeners
}getState()— returns the current state synchronously. Safe to call outside a subscription.subscribe(fn)— called on every state change. Returns an unsubscribe function — always call it on teardown.actions— the only way to mutate state. Mirrors the Command pattern from MVVM Core.eventBus— optional pub-sub bus for side-channel events (selection, navigation) that are not reflected in state.destroy()— clears all internal subscriptions,setTimeouthandles, and event listeners. Call this when the containing component unmounts.
const pattern = createSomePattern({ ... });
// Subscribe to state changes
const unsubscribe = pattern.subscribe((state) => {
render(state);
});
// Trigger actions
pattern.actions.doSomething();
// Teardown
pattern.destroy();
unsubscribe();Master-Detail Pattern
Split-view interface with synchronized list selection and an independent detail panel.
import { createMasterDetail } from '@web-loom/ui-patterns';
const masterDetail = createMasterDetail<Item>({
items: initialItems,
getId: (item) => item.id,
initialDetailView: 'overview',
onSelectionChange: (item) => console.log('Selected:', item),
});Options
items: T[]— initial item arraygetId: (item: T) => string— extract a stable ID (required)initialDetailView?: string— starting detail view name (default:'default')onSelectionChange?: (item: T | null) => void— callback on every selection change
State
items: T[]— full list of itemsselectedItem: T | null— currently selected item (nullif nothing is selected)detailView: string— active detail view identifier
Actions
selectItem(item: T)— set selectionclearSelection()— deselectsetDetailView(view: string)— switch the detail panel's view
Events
'item:selected'— fires with the selected item'selection:cleared'— fires when selection is cleared
Example — React
import { createMasterDetail } from '@web-loom/ui-patterns';
import { useState, useEffect, useMemo } from 'react';
function usePattern<S>(pattern: { getState(): S; subscribe(fn: (s: S) => void): () => void }) {
const [state, setState] = useState(() => pattern.getState());
useEffect(() => pattern.subscribe(setState), [pattern]);
return state;
}
export function GreenhouseExplorer({ greenhouses }: { greenhouses: Greenhouse[] }) {
const md = useMemo(
() => createMasterDetail({ items: greenhouses, getId: (g) => g.id }),
[greenhouses],
);
useEffect(() => () => md.destroy(), [md]);
const { items, selectedItem } = usePattern(md);
return (
<div className="flex">
<ul className="w-64 border-r">
{items.map((g) => (
<li
key={g.id}
className={g.id === selectedItem?.id ? 'bg-blue-50' : ''}
onClick={() => md.actions.selectItem(g)}
>
{g.name}
</li>
))}
</ul>
<div className="flex-1 p-6">
{selectedItem ? <GreenhouseDetail item={selectedItem} /> : <p>Select a greenhouse</p>}
</div>
</div>
);
}Wizard Pattern
Multi-step flows with per-step validation, data accumulation, and optional conditional branching between steps.
import { createWizard } from '@web-loom/ui-patterns';
const wizard = createWizard<OnboardingData>({
steps: [
{
id: 'account-type',
label: 'Account Type',
validate: (data) => (!data.accountType ? 'Select an account type' : null),
getNextStep: (data) => (data.accountType === 'business' ? 'company-info' : 'personal-info'),
},
{ id: 'personal-info', label: 'Personal Info' },
{ id: 'company-info', label: 'Company Info' },
{
id: 'confirm',
label: 'Confirm',
validate: (data) => (!data.agreed ? 'You must agree to continue' : null),
},
],
onComplete: async (data) => {
await api.createAccount(data);
},
onStepChange: (index, step) => analytics.track('wizard_step', { step: step.id }),
});Options
steps: WizardStep[]— ordered step definitions (required)onComplete?: (data: T) => void | Promise<void>— called when the last step passes validationonStepChange?: (index: number, step: WizardStep) => voidonDataChange?: (data: T) => void
WizardStep
id: string— unique identifierlabel: string— display label (used in breadcrumbs and step indicators)validate?: (data: T) => string | null— return an error message string, ornullto proceedgetNextStep?: (data: T) => string | null— return a stepidto branch, ornullfor linear flow
State
steps: WizardStep[]— all step definitionscurrentStepIndex: number— zero-based index of the active stepcompletedSteps: number[]— indices of steps that have passed validationcanProceed: boolean— whether the current step allows moving forwarddata: T— accumulated form data across all steps
Actions
goToNextStep(): Promise<boolean>— runs validation; advances if valid; returnsfalseif blockedgoToPreviousStep()— go back one step (no validation)goToStep(index: number)— jump to any step (for non-linear navigation)setStepData(data: Partial<T>)— merge partial data into the accumulatorcompleteWizard(): Promise<void>— validate the last step and callonComplete
Usage Pattern
// Move forward (with validation)
const advanced = await wizard.actions.goToNextStep();
if (!advanced) {
// show wizard.getState().steps[currentIndex] validation error
}
// Save partial data from a step form
wizard.actions.setStepData({ email: 'alice@example.com' });
// Finish
await wizard.actions.completeWizard();Modal Pattern
Modal dialog stack with priority ordering, ESC handling, and backdrop click support.
import { createModal } from '@web-loom/ui-patterns';
const modal = createModal({
onModalOpened: (m) => console.log('opened', m.id),
onModalClosed: (id) => console.log('closed', id),
onStackChange: (stack) => updateBackdrop(stack.length > 0),
});Options
onModalOpened?: (modal: Modal) => voidonModalClosed?: (modalId: string) => voidonStackChange?: (stack: Modal[]) => void
State
stack: Modal[]— open modals sorted by priority (highest on top)topModalId: string | null— ID of the frontmost modal
Modal Object
id: stringcontent: any— arbitrary payload (component name, props, etc.)priority: number— stack order (default:0; higher values appear on top)closeOnEscape?: booleancloseOnBackdropClick?: boolean
Actions
openModal(id, content, priority?)— push a modal onto the stackopenModalWithConfig(config: OpenModalConfig)— open with full optionscloseModal(id)— remove a specific modalcloseTopModal()— remove the frontmost modalcloseAllModals()— clear the entire stackhandleEscapeKey()— call this onkeydownESC to close the top modal (ifcloseOnEscapeis set)handleBackdropClick(id)— call this on backdrop click (ifcloseOnBackdropClickis set)
Events
'modal:opened','modal:closed','modal:stacked''modal:escape-pressed','modal:backdrop-clicked'
Example
// Open a confirmation dialog
modal.actions.openModal('confirm-delete', { item: selectedItem });
// In a React modal renderer
const { stack } = modal.getState();
return stack.map((m) => (
<Dialog key={m.id} onClose={() => modal.actions.closeModal(m.id)}>
<ConfirmDeleteDialog item={m.content.item} />
</Dialog>
));Tabbed Interface Pattern
Tab navigation with keyboard arrow-key support and optional drag-to-reorder.
import { createTabbedInterface } from '@web-loom/ui-patterns';
const tabs = createTabbedInterface({
tabs: [
{ id: 'overview', label: 'Overview' },
{ id: 'sensors', label: 'Sensors' },
{ id: 'settings', label: 'Settings', disabled: false },
],
initialActiveTabId: 'overview',
orientation: 'horizontal',
wrap: true,
onTabChange: (tabId) => router.navigate(`#${tabId}`),
});Options
tabs?: Tab[]— initial tab definitionsinitialActiveTabId?: string— active tab on mountorientation?: 'horizontal' | 'vertical'(default:'horizontal')wrap?: boolean— keyboard navigation wraps at ends (default:true)onTabChange?: (tabId: string) => void
Tab Object
id: stringlabel: stringdisabled?: boolean(default:false)
State
tabs: Tab[]activeTabId: stringpanels: Map<string, any>
Actions
activateTab(tabId: string)— switch the active tabaddTab(tab: Tab)— append a new tab at runtimeremoveTab(tabId: string)— remove a tab (activates the nearest remaining tab)moveTab(fromIndex, toIndex)— reorder tabsfocusNextTab()— keyboard: advance (used inkeydownhandler)focusPreviousTab()— keyboard: retreat
Keyboard Wiring
// Wire arrow keys in a React component
<TabList role="tablist" onKeyDown={(e) => {
if (e.key === 'ArrowRight') tabs.actions.focusNextTab();
if (e.key === 'ArrowLeft') tabs.actions.focusPreviousTab();
}}>
{state.tabs.map((tab) => (
<Tab
key={tab.id}
role="tab"
aria-selected={tab.id === state.activeTabId}
onClick={() => tabs.actions.activateTab(tab.id)}
>
{tab.label}
</Tab>
))}
</TabList>Sidebar Shell Pattern
Collapsible navigation sidebar with pinning, responsive mobile mode, and active section tracking.
import { createSidebarShell } from '@web-loom/ui-patterns';
const sidebar = createSidebarShell({
initialExpanded: true,
initialActiveSection: 'dashboard',
initialPinned: false,
initialWidth: 260,
onExpand: () => savePreference('sidebar', 'expanded'),
onCollapse: () => savePreference('sidebar', 'collapsed'),
onSectionChange: (s) => router.navigate(`/${s}`),
});Options
initialExpanded?: boolean(default:false)initialActiveSection?: string | null(default:null)initialPinned?: boolean(default:false)initialWidth?: number— pixels (default:250)initialMobile?: boolean(default:false)onExpand?,onCollapse?,onSectionChange?,onMobileChange?
State
isExpanded: booleanactiveSection: string | nullisPinned: booleanwidth: numberisMobile: boolean
Actions
expand(),collapse(),toggle()setActiveSection(section: string)— in mobile mode, auto-collapses after navigationtogglePin()— pin sidebar open (prevents auto-collapse)setWidth(width: number)setMobileMode(isMobile: boolean),toggleMobile()
Events
'sidebar:expanded', 'sidebar:collapsed', 'sidebar:pinned', 'sidebar:unpinned', 'section:changed', 'width:changed', 'sidebar:mobile-toggled'
Responsive Integration
// Drive mobile mode from a resize observer
const resizeObserver = new ResizeObserver(([entry]) => {
const isMobile = entry.contentRect.width < 768;
sidebar.actions.setMobileMode(isMobile);
});
resizeObserver.observe(document.body);Toast Queue Pattern
Auto-dismissing notification queue with configurable position and type-based styling.
import { createToastQueue } from '@web-loom/ui-patterns';
const toasts = createToastQueue({
maxVisible: 4,
defaultDuration: 4000,
position: 'bottom-right',
onToastAdded: (t) => console.log('added', t.id),
onToastRemoved: (id) => console.log('removed', id),
});
// Show a toast
const id = toasts.actions.addToast({
message: 'Greenhouse saved successfully',
type: 'success',
duration: 3000,
});Options
maxVisible?: number(default:3)defaultDuration?: number— ms before auto-dismiss (default:5000)position?: ToastPosition(default:'top-right')onToastAdded?,onToastRemoved?,onPositionChanged?
Toast Object
id: string— auto-generated UUIDmessage: stringtype: 'info' | 'success' | 'warning' | 'error'duration: numbercreatedAt: number—Date.now()timestamp
ToastPosition Values
'top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'
State
toasts: Toast[]— currently visible toasts (at mostmaxVisible)maxVisible: numberdefaultDuration: numberposition: ToastPosition
Actions
addToast(toast): string— enqueue a toast, returns itsidremoveToast(id: string)— dismiss immediatelyclearAllToasts()— dismiss allsetPosition(pos: ToastPosition)— reposition the queue
MVVM Integration
// In a ViewModel, inject the toast queue
class GreenhouseViewModel extends BaseViewModel {
constructor(private toasts: ToastQueueBehavior) { super(); }
readonly saveCommand = new Command(async () => {
await this.model.save();
this.toasts.actions.addToast({ message: 'Saved', type: 'success' });
});
}Command Palette Pattern
Keyboard-driven command interface with fuzzy search filtering over registered commands.
import { createCommandPalette } from '@web-loom/ui-patterns';
const palette = createCommandPalette({
commands: [
{
id: 'new-greenhouse',
label: 'New Greenhouse',
category: 'Actions',
keywords: ['create', 'add'],
shortcut: 'Ctrl+N',
action: () => modal.actions.openModal('new-greenhouse', {}),
},
{
id: 'toggle-theme',
label: 'Toggle Dark Mode',
category: 'Appearance',
action: () => themeStore.actions.toggle(),
},
],
onOpen: () => analytics.track('palette_opened'),
onClose: () => {},
onCommandExecute: (cmd) => analytics.track('command', { id: cmd.id }),
});Options
commands?: Command[]— initial command registryonOpen?,onClose?,onCommandExecute?
Command Object
id: stringlabel: string— primary search targetcategory?: string— group labelkeywords?: string[]— additional search termsshortcut?: string— display hint (e.g.'⌘K')action: () => void | Promise<void>
State
isOpen: booleanquery: string— live search inputcommands: Command[]— full registryfilteredCommands: Command[]— commands matching the current queryselectedIndex: number— keyboard-focused position in filtered list
Actions
open()— show palette (resets query and selection)close()— hide palettesetQuery(query: string)— update search;filteredCommandsupdates automaticallyregisterCommand(cmd)— add/update a command at runtimeunregisterCommand(id)— remove a commandselectNext(),selectPrevious()— keyboard up/down navigationexecuteSelected(): Promise<void>— run the currently highlighted commandexecuteCommand(id): Promise<void>— run by ID
Fuzzy Search
The built-in fuzzy match algorithm scores by:
- Consecutive character runs (+5 per run)
- Word boundary matches (+10)
- Start-of-string matches (+10)
- Matches in
categoryandkeywordsin addition tolabel
Full Example — React
export function CommandPaletteShell() {
const [state, setState] = useState(() => palette.getState());
useEffect(() => palette.subscribe(setState), []);
// Open with ⌘K / Ctrl+K
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
palette.actions.open();
}
if (e.key === 'Escape') palette.actions.close();
if (e.key === 'ArrowDown') palette.actions.selectNext();
if (e.key === 'ArrowUp') palette.actions.selectPrevious();
if (e.key === 'Enter') palette.actions.executeSelected();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
if (!state.isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24 bg-black/40">
<div className="w-full max-w-xl bg-white dark:bg-slate-900 rounded-xl shadow-2xl">
<input
autoFocus
value={state.query}
onChange={(e) => palette.actions.setQuery(e.target.value)}
placeholder="Type a command…"
className="w-full px-4 py-3 text-lg border-b"
/>
<ul>
{state.filteredCommands.map((cmd, i) => (
<li
key={cmd.id}
className={i === state.selectedIndex ? 'bg-blue-50 dark:bg-slate-700' : ''}
onClick={() => palette.actions.executeCommand(cmd.id)}
>
<span>{cmd.label}</span>
{cmd.shortcut && <kbd>{cmd.shortcut}</kbd>}
</li>
))}
</ul>
</div>
</div>
);
}Hub-and-Spoke Navigation Pattern
Central hub page with independent spoke sections, breadcrumb trail, and optional browser history integration.
import { createHubAndSpoke } from '@web-loom/ui-patterns';
const nav = createHubAndSpoke({
spokes: [
{ id: 'analytics', label: 'Analytics', icon: 'chart' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
{
id: 'greenhouses',
label: 'Greenhouses',
subSpokes: [
{ id: 'greenhouse-list', label: 'All Greenhouses' },
{ id: 'greenhouse-create', label: 'Add Greenhouse' },
],
},
],
enableBrowserHistory: true,
onSpokeActivate: (id) => console.log('navigated to', id),
onReturnToHub: () => console.log('back to home'),
});Options
spokes: Spoke[]— required; top-level navigation sectionsenableBrowserHistory?: boolean(default:false) — push/pop history statesonSpokeActivate?: (spokeId: string) => voidonReturnToHub?: () => void
Spoke Object
id: stringlabel: stringicon?: stringsubSpokes?: Spoke[]— optional nesting for hierarchical navigation
State
isOnHub: boolean— whether the hub page is activeactiveSpoke: string | nullspokes: Spoke[]breadcrumbs: string[]— e.g.['Greenhouses', 'All Greenhouses']navigationHistory: string[]
Actions
activateSpoke(spokeId: string)— navigate to a sectionreturnToHub()— go back to the hubgoBack()— navigate back one step in historyupdateBreadcrumbs(crumbs: string[])— manually update the breadcrumb trailaddSpoke(spoke: Spoke)— register a new spoke at runtimeremoveSpoke(spokeId: string)— unregister; returns to hub if it was active
Events
'spoke:activated', 'hub:returned', 'navigation:changed'
Grid Layout Pattern
Responsive grid with configurable breakpoints, 2D keyboard navigation (arrow keys), and flexible selection modes.
import { createGridLayout } from '@web-loom/ui-patterns';
const grid = createGridLayout<Product>({
items: products,
getId: (p) => p.id,
breakpoints: [
{ minWidth: 0, columns: 2 },
{ minWidth: 640, columns: 3 },
{ minWidth: 1024, columns: 4 },
{ minWidth: 1280, columns: 5 },
],
selectionMode: 'multi',
wrap: true,
initialViewportWidth: window.innerWidth,
onSelectionChange: (selected) => setCart(selected),
});
// Update columns when the viewport resizes
window.addEventListener('resize', () => {
grid.actions.updateViewportWidth(window.innerWidth);
});Options
items: T[]— requiredgetId: (item: T) => string— requiredbreakpoints: Breakpoint[]— required; sorted byminWidthascendingselectionMode?: 'single' | 'multi'(default:'single')wrap?: boolean(default:true)initialViewportWidth?: number(default:1024)initialFocusedIndex?: number(default:0)onSelectionChange?: (selected: T[]) => void
Breakpoint Object
minWidth: number— viewport width threshold in pixelscolumns: number— number of grid columns at this breakpoint
State
items: T[]columns: number— currently active column countselectedItems: string[]— IDs of selected itemsfocusedIndex: numberbreakpoint: Breakpoint— active responsive breakpointviewportWidth: numberselectionMode: GridSelectionModewrap: boolean
Actions
selectItem(itemId: string)— select an item (clears others insinglemode)navigateUp(),navigateDown(),navigateLeft(),navigateRight()— arrow key navigation across rows and columnssetBreakpoints(breakpoints: Breakpoint[])— replace responsive configupdateViewportWidth(width: number)— recalculate active breakpoint and column countsetItems(items: T[])— replace the full item arraysetFocusedIndex(index: number)— programmatically move focus
Events
'item:focused'— emits{ index, itemId }'item:selected'— emits{ itemId, selectedIds }'breakpoint:changed'— emits the newBreakpoint
Keyboard Wiring
// Wire arrow keys in a React grid
<div
role="grid"
onKeyDown={(e) => {
const map: Record<string, () => void> = {
ArrowUp: () => grid.actions.navigateUp(),
ArrowDown: () => grid.actions.navigateDown(),
ArrowLeft: () => grid.actions.navigateLeft(),
ArrowRight: () => grid.actions.navigateRight(),
};
map[e.key]?.();
}}
>
{state.items.map((item, i) => (
<div
key={item.id}
role="gridcell"
tabIndex={i === state.focusedIndex ? 0 : -1}
aria-selected={state.selectedItems.includes(item.id)}
onClick={() => grid.actions.selectItem(item.id)}
>
{item.name}
</div>
))}
</div>Floating Action Button (FAB) Pattern
Scroll-aware primary action button that appears after the user has scrolled a configurable threshold, with optional auto-hide on scroll-down.
import { createFloatingActionButton } from '@web-loom/ui-patterns';
const fab = createFloatingActionButton({
scrollThreshold: 200, // show after scrolling 200 px
hideOnScrollDown: true, // hide when scrolling down (show only when scrolling up)
onVisibilityChange: (visible) => {
document.getElementById('fab')!.style.display = visible ? 'flex' : 'none';
},
});
// Feed scroll position from a scroll listener
window.addEventListener('scroll', () => {
fab.actions.setScrollPosition(window.scrollY);
});Options
scrollThreshold?: number(default:100) — pixels scrolled before FAB appearshideOnScrollDown?: boolean(default:false) — hide FAB while scrolling down, re-show on scroll-uponVisibilityChange?: (visible: boolean) => void
State
isVisible: booleanscrollPosition: number— current scroll offset in pixelsscrollDirection: 'up' | 'down' | nullscrollThreshold: numberhideOnScrollDown: boolean
Actions
show(),hide(),toggle()— manual visibility controlsetScrollPosition(position: number)— drives automatic show/hide logicsetScrollThreshold(threshold: number)— adjust the scroll threshold at runtimesetHideOnScrollDown(hide: boolean)— toggle direction-sensitive hiding
Events
'fab:shown', 'fab:hidden', 'fab:visibility-changed' (emits boolean), 'fab:scroll-direction-changed' (emits direction)
"Back to Top" Example
const backToTop = createFloatingActionButton({ scrollThreshold: 300 });
window.addEventListener('scroll', () =>
backToTop.actions.setScrollPosition(window.scrollY),
);
document.getElementById('back-to-top')!.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
backToTop.subscribe(({ isVisible }) => {
document.getElementById('back-to-top')!.style.opacity = isVisible ? '1' : '0';
});Event Bus Integration
Patterns that emit side-channel events expose an eventBus property. Listener cleanup is handled automatically by destroy(), but you can also unsubscribe manually using the function returned by on().
const md = createMasterDetail({ items, getId });
// Listen to side-channel events
const off = md.eventBus.on('item:selected', (item) => {
detailPane.load(item.id);
});
// Later
off(); // unsubscribe just this listener
md.destroy(); // unsubscribes all listeners + cleans up stateMVVM Integration
Patterns are stateful objects, not ViewModels — but they compose cleanly with ViewModels. The recommended pattern is to pass a pattern instance into a ViewModel constructor or as a property.
import { createToastQueue, createModal, type ToastQueueBehavior, type ModalBehavior } from '@web-loom/ui-patterns';
import { BaseViewModel, Command } from '@web-loom/mvvm-core';
class GreenhouseViewModel extends BaseViewModel {
constructor(
private model: GreenhouseModel,
private toasts: ToastQueueBehavior,
private modal: ModalBehavior,
) {
super();
}
readonly deleteCommand = new Command(async (id: string) => {
await this.model.delete(id);
this.toasts.actions.addToast({ message: 'Deleted', type: 'success' });
});
readonly confirmDelete = (id: string) => {
this.modal.actions.openModal('confirm-delete', { id });
};
}
// Singletons shared across the app
export const toastQueue = createToastQueue({ maxVisible: 3 });
export const globalModal = createModal();
export const greenhouseVM = new GreenhouseViewModel(
new GreenhouseModel(),
toastQueue,
globalModal,
);Framework Integration
Because patterns follow the subscribe() / getState() contract, the same adapter hook works for any pattern.
React
function usePattern<S>(p: { getState(): S; subscribe(fn: (s: S) => void): () => void }): S {
const [state, setState] = useState(() => p.getState());
useEffect(() => p.subscribe(setState), [p]);
return state;
}Vue 3
function usePattern<S>(p: { getState(): S; subscribe(fn: (s: S) => void): () => void }) {
const state = ref<S>(p.getState());
let unsub: (() => void) | null = null;
onMounted(() => { unsub = p.subscribe((s) => { state.value = s; }); });
onUnmounted(() => { unsub?.(); p.destroy(); });
return readonly(state);
}Angular
@Injectable({ providedIn: 'root' })
export class ToastService implements OnDestroy {
private queue = createToastQueue({ maxVisible: 4 });
private _state$ = new BehaviorSubject(this.queue.getState());
constructor() {
this.queue.subscribe((s) => this._state$.next(s));
}
get state$() { return this._state$.asObservable(); }
get actions() { return this.queue.actions; }
ngOnDestroy() { this.queue.destroy(); }
}Testing
Because patterns have no framework dependencies, they can be tested with plain Vitest.
import { describe, it, expect, vi } from 'vitest';
import { createWizard } from '@web-loom/ui-patterns';
describe('createWizard', () => {
it('validates before advancing', async () => {
const onComplete = vi.fn();
const wizard = createWizard({
steps: [
{ id: 'step1', label: 'Step 1', validate: (d) => (!d.name ? 'Required' : null) },
{ id: 'step2', label: 'Step 2' },
],
onComplete,
});
const advanced = await wizard.actions.goToNextStep();
expect(advanced).toBe(false); // blocked by validation
wizard.actions.setStepData({ name: 'Alice' });
const advanced2 = await wizard.actions.goToNextStep();
expect(advanced2).toBe(true);
expect(wizard.getState().currentStepIndex).toBe(1);
wizard.destroy();
});
it('calls onComplete when wizard finishes', async () => {
const onComplete = vi.fn();
const wizard = createWizard({
steps: [{ id: 'only', label: 'Only Step' }],
onComplete,
});
await wizard.actions.completeWizard();
expect(onComplete).toHaveBeenCalledOnce();
wizard.destroy();
});
});Best Practices
- Always call
destroy()in component teardown — patterns setsetTimeouttimers (Toast), addkeydownlisteners (Modal), and subscribe to stores internally. - One pattern instance per feature scope — create modal or toast singletons at the app level and inject them into ViewModels. Don't create new instances per render.
- Use
eventBusfor cross-concern side effects — subscribe to'item:selected'in a ViewModel, not in a render function. - Combine patterns — a typical dashboard composes Sidebar Shell + Tabbed Interface + Master-Detail + Toast Queue + Command Palette. Each is independent; they communicate through shared ViewModels or event bus listeners.
- Feed responsive data from outside —
GridLayoutandSidebarShelldon't attach resize listeners themselves. Drive them from a single ResizeObserver or media query hook to keep observation centralised. - Register commands at boot — for
CommandPalette, register all commands once at app initialisation. UseregisterCommandat runtime only for context-sensitive commands (e.g. editing mode).
See Also
- UI Core Behaviors — the atomic behaviors (
createListSelection,createRovingFocus,createDialogBehavior,createDisclosureBehavior) that patterns build on - Store Core — reactive store used internally by all patterns
- Event Bus Core — the event bus used by pattern
eventBusproperties - Design Core — design tokens and theming that styles pattern UI