OEM is an agent-first UI framework and toolkit engineered for human-AI collaboration. It provides a declarative syntax for composing reactive UIs in 100% TypeScript.
The following documentation describes the core concepts, libraries, and conventions of OEM. The sectsions in this document are normative unless otherwise specified.
- useScrollIntoViewTrait - Reactively scrolls an HTML element into view based on conditions
- useAnimationTrait - Applies Web Animations API keyframe animations to an HTML element with reactive state and condition support
- useDataAttributeTrait - Reactively sets data-* attributes on an HTML element via the dataset API
- useFocusTrait - Focuses an HTML element based on conditions
- useInputEventTrait - Handles input-related events on form elements
- useTextContentTrait - Sets the text content of an HTML element
- useAttributeTrait - Adds an attribute to an HTML element
- useStyleTrait - Applies CSS styles to an HTML element
- useInputValueTrait - Binds a value to an input or textarea element
- useEventTrait - Attaches event listeners to an HTML element
- useInnerHTMLTrait - Sets the innerHTML of an element with reactive children
- useAriaTrait - Reactively sets ARIA attributes and roles on an HTML element
- useClassNameTrait - Sets the class name of an HTML element
- useStyleOnEventTrait - Applies CSS styles to an element on a specific event
- useUrlState - State Object to track the current URL in the application.
- useMediaQueryState - Reactive state for tracking media query matches based on viewport width and media type
- useThemeState - A simple State object of 'light' and 'dark'
- useOnlineState - Reactive state for tracking browser online/offline connectivity
- useWindowSizeState - Reactive state for tracking viewport width and height
- useTokenState - A simple State object to manage a single design token setting
- useIntersectionObserverState - Reactive state for tracking element visibility within the viewport
- useTimerState - Reactive interval-driven counter state with start, stop, and reset controls
- useFormState - Reactive form state with validation, dirty tracking, and field-level control
- Theme Toggle Example - A theme toggle component built with OEM.
- Dynamic List Example - A dynamic list component built with OEM.
- Counter Example - A simple counter component built with OEM.
- Conditional Styling Example - A conditional styling component built with OEM.
To get started with OEM, install the package from npm:
npm install @linttrap/oem
// define a template engine
const [tag, trait] = Template({
style: useStyleTrait,
event: useEventTrait,
text: useTextTrait,
})
// define state
const count = State(0);
// define your dom
const counter = tag.div(
trait.style('display', 'flex'),
trait.style('alignItems', 'center'),
trait.style('gap', '16px'),
tag.span(
trait.text(count.$val),
trait.style('fontSize', '48px'),
trait.style('fontWeight', '700'),
trait.style('color', '#555555'),
),
tag.button(
trait.text('+'),
trait.event('click', count.$reduce((n) => n + 1)),
trait.style('fontSize', '24px'),
),
);
// that's it! You just defined: behavior, state, and presentation in one cohesive block of code.
The template module allows you to create your own template engine. It is the core mechanism that supports OEM's unique approach to declarative, reactive applications that combine HTML, Styling, Logic and Behavior.
Template<P>function Template<P extends Record<string, TemplateTraitFunc>>(config?: P): TemplateReturnType<P>config: Optional object mapping trait names to trait implementation functions[tagProxy, traitProxy] typically destructured as [tag, trait]// Import Template and some traits from OEM
import { Template, useEventTrait, useTextContentTrait, useStyleTrait } from '@linttrap/oem';
// Create template and add traits
export const [tag, trait] = Template({
event: useEventTrait,
text: useTextContentTrait,
style: useStyleTrait,
});
// Now you can generate elements with the tag proxy and apply available traits
const button = tag.button(
trait.event('click', () => console.log('Clicked!')),
trait.text('Click Me'),
trait.style('backgroundColor', 'blue'),
);
(...traits: Trait[]) => HTMLElement | SVGElement(...args: Parameters<TraitFunc>) => (el: HTMLElement) => voidTemplateTraitFunc(...args: Args) => Return - Function type for trait implementationsTemplateTraitApplier(el: HTMLElement | SVGElement) => void - Function that applies a trait to an elementTemplateReturnType<P>The template module uses a sophisticated cleanup system:
SVG elements are created using createElementNS with the SVG namespace. The module maintains a hardcoded set of common SVG tag names for detection.
A trait is any function that takes an element as its first parameter, applies some behavior or configuration to it and returns a cleanup function. Traits can be defined in the config object passed to Template() and are accessed via the trait proxy.
Here's an example of the implementation of the Style Trait
export function useStyleTrait(
el: HTMLElement,
prop: keyof CSSStyleDeclaration | `--${string}`,
val: (() => string | number | undefined) | (string | number | undefined),
...rest: (StateType<any> | Condition)[]
) {
const states = extractStates(val, ...rest);
const conditions = extractConditions(...rest);
const apply = () => {
const _val = typeof val === 'function' ? val() : val;
const applies = conditions.every((i) => (typeof i === 'function' ? i() : i));
if (applies) {
(prop as string).startsWith('--')
? el.style.setProperty(prop as string, _val as string)
: (el.style[prop as any] = _val as any);
}
};
apply();
const unsubs = states.map((state) => state.sub(apply));
return () => unsubs.forEach((unsub) => unsub());
}
IMPORTANT: OEM prescribes using explicit conditions rather than ternary expressions when applying traits.
All traits accept ...rest: (StateType<any> | Condition)[] parameters. Use $test() from @/core/util to create conditions:
import { $test } from '@linttrap/oem';
// ✅ CORRECT: Use separate trait calls with conditions
trait.style('opacity', '0.6', $test(disabled)),
trait.style('opacity', '1', $test(!disabled)),
// ✅ CORRECT: Multiple conditions
trait.style('backgroundColor', 'red', $test(isError && !disabled)),
// ✅ CORRECT: Conditional event handlers
trait.event('click', handleClick, $test(!disabled)),
// ✅ CORRECT: Conditional attributes
trait.attr('disabled', 'true', $test(disabled)),
Note: State objects come with a built-in $test method that creates a condition based on the state value (e.g. state.$test(true) creates a condition that checks if the state value is true). See State.md for more details.
#### Avoiding Ternary Expressions (Anti-pattern)
```typescript
// ❌ INCORRECT: Do not use ternary expressions
trait.style('opacity', disabled ? '0.6' : '1'),
// ❌ INCORRECT: Do not use conditional spreads
...(!disabled ? [trait.event('click', handleClick)] : []),
// ❌ INCORRECT: Do not use inline conditionals
trait.style('color', isError ? 'red' : 'blue'),
Traits extract conditions using extractConditions() (see @/core/util) and only apply when all conditions evaluate to true:
$test(value, expected = true) creates a condition that checks if value === expectedtype === '$test'This pattern ensures:
The state function is an an event bus with reactive state management convention used by the OEM framework. It implements a publish-subscribe pattern with support for reducing, testing and augmenting with custom methods. State is designed to be simple and flexible, allowing you to manage reactive data in your applications without the overhead of more complex state management libraries.
State solves the problem of managing reactive data in applications where UI components need to automatically update when data changes. It provides a lightweight alternative to more complex state management libraries, with built-in support for:
Use this module when you need to manage any state, especially with reactive data binding between your application state and UI components. State objects should live outside of function components to ensure they are shared across the application and not recreated on each render.
State<T, M>function State<T, M>(param: T, customMethods?: M): StateType<T, M>param: The initial value for the statecustomMethods (optional): An object containing custom methods that receive the state object as their first parameterStateType<T, M> object with methods for state management// Basic usage
const count = State(0);
count.sub((value) => console.log('Count changed:', value));
count.set(5); // Logs: "Count changed: 5"
// With custom methods
const counter = State(
{ count: 0 },
{
increment: (state) => {
state.reduce((prev) => ({ count: prev.count + 1 }));
},
incrementBy: (state, amount: number) => {
state.reduce((prev) => ({ count: prev.count + amount }));
},
},
);
counter.increment(); // Increments count by 1
counter.incrementBy(5); // Increments count by 5
counter.$increment()(); // Deferred increment
Note: the $-prefixed methods (e.g. $increment) are automatically generated for each custom method and return a closure that can be used for deferred execution, such as in event handlers.
StateType<T>State() function, providing methods for state manipulation and observationval()const currentValue = count.val();set(atom: T)atom - The new value to setreduce(cb: (prev: T) => T)cb - A function that receives the current value and returns the new valuecount.reduce(prev => prev + 1);sub(cb: (atom: T) => any)cb - Callback function called with new value on each changeconst unsub = count.sub((value) => console.log(value));
// Later: unsub() to stop listening
test(predicate, checkFor?)predicate: A RegExp, value, or function to test againstcheckFor: Optional boolean (default true) to invert the test resultconst isZero = count.test(0); // true if count is 0
const isPositive = count.test((v) => v > 0);
Each core method has a dollar-prefixed version ($val, $set, $reduce, $test, $call) that returns a closure for deferred execution. These closures include:
sub property for subscribing to changestype property identifying the closure typeUsage Example:
const getDouble = count.$reduce((prev) => prev * 2);
// Later: getDouble() executes the reduction
State supports extending functionality with custom methods via the second parameter. Custom methods:
Usage Example:
type CounterState = { count: number; name: string };
const counter = State<CounterState>(
{ count: 0, name: 'MyCounter' },
{
increment: (state) => {
state.reduce((prev) => ({ ...prev, count: prev.count + 1 }));
},
incrementBy: (state, amount: number) => {
state.reduce((prev) => ({ ...prev, count: prev.count + amount }));
},
reset: (state) => {
state.set({ count: 0, name: state.val().name });
},
getDisplayText: (state) => {
const { count, name } = state.val();
return `${name}: ${count}`;
},
},
);
// Direct execution
counter.increment();
counter.incrementBy(5);
console.log(counter.getDisplayText()); // "MyCounter: 6"
// Deferred execution with $ prefix
const incrementBtn = document.querySelector('#increment');
incrementBtn.addEventListener('click', counter.$increment());
const addFiveBtn = document.querySelector('#add-five');
addFiveBtn.addEventListener('click', counter.$incrementBy(5));
Because custom methods take the state object as their first parameter, they have access to all state methods:
state.val() - Get current valuestate.set(newValue) - Set new valuestate.reduce(cb) - Update based on previous valuestate.sub(cb) - Subscribe to changesstate.test(predicate) - Test current valueThe state module uses a closure-based approach to maintain private state:
_internalValSet<(atom: T) => any>set() or reduce(), all subscribers are notified synchronouslypub method; state updates are published automatically when using set() or reduce()$ prefix) return closures that are syntactic sugar for traits. Traits use them to get the current state value and subscribe to changes. Each trait is responsible for implementing the logic to re-run when the state changes. This design allows for flexible reactive patterns without coupling state directly to UI components.The util module provides utility functions for working with State objects and Conditions in the OEM framework. It offers runtime helpers for filtering, testing, and extracting reactive objects from mixed arrays.
Util solves the problem of identifying and working with OEM's special object types (State, Condition) at runtime. Key features include:
Use this module when you need to programmatically work with State or Condition objects, particularly when processing variable argument lists that may contain a mix of different types.
$test()function $test(val: (() => any) | any, expected: any = true): Conditionval: Either a function that returns a value, or a static valueexpected: The expected value to compare against (default: true)Condition function with a type property set to '$test'// Test a static value
const isTrue = $test(true); // () => true === true
// Test a function result
const count = State(5);
const isPositive = $test(() => count.val() > 0, true);
// Use in conditional rendering
const element = h.div(isPositive && h.span('Count is positive'));
// Custom expected value
const isFive = $test(count.val, 5);
val is a function, it's called on each evaluationval is a static value, it's compared directlyval === expected, false otherwisetype property for runtime identificationextractStates()function extractStates(...rest: any): StateType<any>[]...rest: Variable number of arguments of any typeconst count = State(0);
const name = State('Alice');
const regularValue = 42;
const regularFunc = () => 'hello';
const states = extractStates(count, regularValue, name, regularFunc);
// states = [count, name]
// Use case: Subscribing to all states in a component
states.forEach((state) => {
state.sub((value) => {
console.log('State changed:', value);
});
});
sub property (characteristic of State objects)extractConditions()function extractConditions(...rest: any[]): Condition[]...rest: Variable number of arguments of any typeconst isVisible = $test(true);
const isEnabled = $test(() => count.val() > 0);
const regularValue = 42;
const regularFunc = () => 'hello';
const conditions = extractConditions(isVisible, regularValue, isEnabled, regularFunc);
// conditions = [isVisible, isEnabled]
// Use case: Evaluating all conditions
const allTrue = conditions.every((condition) =>
typeof condition === 'function' ? condition() : condition,
);
type property equal to '$test'Both extractStates and extractConditions use runtime property checking to identify objects:
Object.hasOwn(i, 'sub') to check for the subscription methodi.type === '$test' to check for the type markerThis approach relies on duck typing rather than instanceof checks, making it flexible but requiring consistent object shapes.
The $test function returns a closure with:
type: '$test')extractStates returns StateType<any>[], losing specific type informationextractConditions returns Condition[]any types internally for flexibility with mixed arraysReactively scrolls an element into the visible area of its scrollable ancestor when conditions are met. Supports smooth and instant scrolling via standard ScrollIntoViewOptions.
useScrollIntoViewTrait(
el: HTMLElement,
options?: ScrollIntoViewOptions,
...rest: (StateType<any> | Condition)[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target element to scroll into view |
options | ScrollIntoViewOptions | Standard scroll options: behavior ('smooth' or 'instant'), block ('start', 'center', 'end', 'nearest'), inline (same) |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. Scrolls when all Conditions are truthy. Re-evaluates on State changes. |
el.scrollIntoView(options).rest so the trait re-evaluates on state changes.A cleanup function that unsubscribes from all State listeners.
// Scroll when an item becomes active
trait.scrollIntoView(
{ behavior: 'smooth', block: 'center' },
activeItem.$test((id) => id === itemId),
);
// Scroll to error field on validation failure
trait.scrollIntoView(
{ behavior: 'smooth', block: 'start' },
form.$test((f) => !!f.errors.email && f.touched.email),
);
// Always scroll into view on mount (no conditions)
trait.scrollIntoView({ behavior: 'instant', block: 'start' });
const messages = State<Message[]>([]);
// Scroll the last message into view when messages change
tag.div(
trait.scrollIntoView(
{ behavior: 'smooth', block: 'end' },
messages.$test(() => true),
),
);
const activeSection = State<string>('intro');
// Each section scrolls into view when it becomes active
['intro', 'features', 'pricing'].forEach((id) => {
tag.section(
trait.scrollIntoView({ behavior: 'smooth', block: 'start' }, activeSection.$test(id)),
);
});
const form = useFormState({ name: '', email: '' }, validators);
tag.input(
trait.scrollIntoView(
{ behavior: 'smooth', block: 'center' },
form.$test((f) => !!f.errors.name && f.touched.name),
),
);
Element.scrollIntoView() API{ behavior: 'smooth' } for user-visible navigation and { behavior: 'instant' } for programmatic repositioningReactively plays a Web Animations API keyframe animation on an element. Supports conditional gating, state-driven re-triggering, and automatic cleanup.
useAnimationTrait(
el: HTMLElement,
keyframes:
| Keyframe[]
| PropertyIndexedKeyframes
| (() => Keyframe[] | PropertyIndexedKeyframes),
options:
| number
| KeyframeAnimationOptions
| (() => number | KeyframeAnimationOptions),
...rest: (StateType<any> | Condition)[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target element |
keyframes | Keyframe[] | PropertyIndexedKeyframes | (() => Keyframe[] | PropertyIndexedKeyframes) | The animation keyframes. Pass an array of Keyframe objects, a PropertyIndexedKeyframes object, or a function that returns either for reactive evaluation. |
options | number | KeyframeAnimationOptions | (() => number | KeyframeAnimationOptions) | Animation timing. Pass a duration in ms, a full KeyframeAnimationOptions object, or a function for reactive evaluation. |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. The animation plays only when all Conditions are truthy. |
keyframes and options (calls them if they are functions).el.animate().rest so the animation re-triggers on state changes.A cleanup function that cancels the running animation and unsubscribes from all State listeners.
trait.animation([{ opacity: '0' }, { opacity: '1' }], {
duration: 200,
easing: 'ease-out',
fill: 'forwards',
});
trait.animation(
[
{ opacity: '0', transform: 'translateY(8px)' },
{ opacity: '1', transform: 'translateY(0)' },
],
{ duration: 250, easing: 'ease-out', fill: 'forwards' },
);
const mode = State<'enter' | 'exit'>('enter');
// Enter animation — plays when mode is 'enter'
trait.animation(
[
{ opacity: '0', transform: 'scale(0.95)' },
{ opacity: '1', transform: 'scale(1)' },
],
{ duration: 200, easing: 'ease-out', fill: 'forwards' },
mode.$test('enter'),
mode,
);
// Exit animation — plays when mode is 'exit'
trait.animation(
[
{ opacity: '1', transform: 'scale(1)' },
{ opacity: '0', transform: 'scale(0.95)' },
],
{ duration: 150, easing: 'ease-in', fill: 'forwards' },
mode.$test('exit'),
mode,
);
trait.animation([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], {
duration: 800,
iterations: Infinity,
easing: 'linear',
});
const speed = State<number>(300);
trait.animation(
[{ opacity: '0' }, { opacity: '1' }],
() => ({ duration: speed.val(), easing: 'ease-out', fill: 'forwards' as FillMode }),
speed,
);
const visible = State<boolean>(false);
trait.animation(
[{ opacity: '0' }, { opacity: '1' }],
{ duration: 200, fill: 'forwards' },
visible.$test(true),
visible,
);
trait.animation(
[{ transform: 'scale(1)' }, { transform: 'scale(1.05)' }, { transform: 'scale(1)' }],
{ duration: 600, easing: 'ease-in-out', iterations: 2 },
);
transform and opacity, delivering 60fps performance without layout thrashing.width, height, top, left, margin, padding) — they force reflow on every frame. Use transform: translate/scale instead.fill: 'forwards' when the element should retain its final animated state.prefers-reduced-motion. When the user has reduced motion enabled, either skip the animation entirely (via a condition tied to a media query state) or use duration: 0 to apply the final state instantly.const reducedMotion = useMediaQueryState('(prefers-reduced-motion: reduce)');
trait.animation(
[{ opacity: '0' }, { opacity: '1' }],
() => ({ duration: reducedMotion.test(true) ? 0 : 200, fill: 'forwards' as FillMode }),
reducedMotion,
);
Reactively sets or removes data-* attributes on an element using the dataset API. Accepts either bare names (e.g. 'active') or prefixed names (e.g. 'data-active'). When the value is undefined or conditions are falsy, the data attribute is removed.
useDataAttributeTrait(
el: HTMLElement,
name: string,
val: (() => string | number | boolean | undefined) | (string | number | boolean | undefined),
...rest: (StateType<any> | Condition)[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target element |
name | string | The data attribute name. Can be bare ('active') or prefixed ('data-active'). Automatically converted to the proper dataset key. |
val | (() => string | number | boolean | undefined) | string | number | boolean | undefined | The attribute value. Pass a function for reactive evaluation. undefined removes the attribute. |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. The trait re-evaluates whenever a State publishes and only applies when all Conditions are truthy. |
name — adds data- prefix if missing, converts to camelCase dataset key.val (calls it if it's a function).val is undefined, removes the data attribute.el.dataset[key] = String(val).rest so the trait re-runs on state changes.A cleanup function that unsubscribes from all State listeners.
// Static data attribute
trait.data('id', '42');
// → el.dataset.id = '42' (renders as data-id="42")
// With data- prefix (also works)
trait.data('data-id', '42');
// → same result
// Reactive value
trait.data('active-tab', () => activeTab.val(), activeTab);
// → el.dataset.activeTab = 'home' (renders as data-active-tab="home")
// Conditional data attribute
trait.data(
'selected',
'true',
item.$test((i) => i.selected),
);
// Remove when undefined
trait.data('tooltip', () => tooltipText.val() || undefined, tooltipText);
// Set data-state for CSS-based styling or external queries
trait.data('state', () => panelState.val(), panelState);
// → [data-state="open"], [data-state="closed"]
items.forEach((item) =>
tag.li(
trait.data('id', item.id),
trait.event('click', (e) => {
const id = (e.target as HTMLElement).dataset.id;
selectItem(id);
}),
),
);
trait.data('testid', 'submit-button');
| Feature | useDataAttributeTrait | useAttributeTrait |
|---|---|---|
| API | Uses el.dataset (camelCase keys) | Uses el.setAttribute |
| Name handling | Auto-prefixes data- if missing | Requires full attr name |
| Intended use | data-* attributes only | Any HTML attribute |
'active-tab' → dataset.activeTab → renders as data-active-tabdataset API for setting/removing, which is the idiomatic way to work with data attributesdelete el.dataset[key] which fully removes the attribute from the DOMProgrammatically focuses an HTML element. Unlike most traits, Focus accepts conditions and states as explicit arrays rather than using the rest-parameter extraction pattern.
useFocusTrait(
el: HTMLElement,
conditions?: Condition[],
states?: StateType<any>[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target element to focus |
conditions | Condition[] | Optional array of Conditions. The element is focused only when all evaluate to truthy. Defaults to []. |
states | StateType<any>[] | Optional array of State objects to subscribe to. The trait re-evaluates whenever any State publishes. Defaults to []. |
el.focus().A cleanup function that unsubscribes from all State listeners.
trait.focus([modalState.$test((s) => s.open)], [modalState]);
trait.focus([], [routeState]);
Attaches an input-related event listener that extracts the value from e.target.value and passes it to a setter function. Designed for binding form element changes directly to state updates.
useInputEventTrait(
el: HTMLElement,
evt: 'input' | 'change' | 'keyup' | 'keydown' | 'keypress' | 'beforeinput' | 'paste' | 'cut' | 'compositionstart' | 'compositionupdate' | 'compositionend',
setter: (val: any) => void,
...rest: (StateType<any> | Condition)[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target form element (typically HTMLInputElement or HTMLTextAreaElement) |
evt | string | The input event type. Restricted to: 'input', 'change', 'keyup', 'keydown', 'keypress', 'beforeinput', 'paste', 'cut', 'compositionstart', 'compositionupdate', 'compositionend'. |
setter | (val: any) => void | A function called with e.target.value when the event fires. Typically a State's set method. |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. The listener is attached only when all Conditions are truthy. |
setter(e.target.value).A cleanup function that removes the event listener and unsubscribes from all State listeners.
trait.inputEvent('input', nameState.set);
trait.inputEvent('change', (val) => filterState.set(val));
trait.inputEvent('keydown', searchState.set, enabled.$test(true));
Sets the text content of an element reactively. Supports single values, arrays of values (concatenated as text nodes), and function getters.
useTextContentTrait(
el: HTMLElement,
text: TextContent | TextContent[] | (() => TextContent | TextContent[]),
...rest: (StateType<any> | Condition)[]
) => () => void
Where TextContent = string | number | undefined | unknown.
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target element |
text | TextContent | TextContent[] | (() => TextContent | TextContent[]) | The text to display. Can be a single value, an array of values (each appended as a text node), or a function returning either. |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. The trait re-evaluates on state changes and only applies when all Conditions are truthy. |
el.textContent.text (calls it if it's a function).undefined values and appends each as a text node.el.textContent to the stringified value.rest so the trait re-runs on state changes.A cleanup function that unsubscribes from all State listeners.
trait.textContent('Hello, World!');
trait.textContent(counter.$val);
trait.textContent(() => `${items.val().length} items`, items);
Sets or removes an HTML attribute on an element reactively. When the value is undefined or conditions evaluate to false, the attribute is removed from the element.
useAttributeTrait(
el: HTMLElement,
prop: string,
val: (() => string | number | boolean | undefined) | (string | number | boolean | undefined),
...rest: (StateType<any> | Condition)[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target element |
prop | string | The attribute name (e.g. 'disabled', 'aria-label', 'data-id') |
val | (() => string | number | boolean | undefined) | string | number | boolean | undefined | The attribute value. Pass a function for reactive evaluation, or a static value. undefined removes the attribute. |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. The trait re-evaluates whenever a State publishes and only applies when all Conditions are truthy. |
val (calls it if it's a function).val is undefined, removes the attribute.el.setAttribute(prop, String(val)).rest so the trait re-runs on state changes.A cleanup function that unsubscribes from all State listeners.
When used through a Template's trait proxy, the el parameter is supplied automatically:
trait.attribute(
'disabled',
'true',
formState.$test((s) => !s.isValid),
);
trait.attribute('aria-label', 'Close dialog');
trait.attribute(
'data-active',
'true',
tabState.$test((s) => s.active === id),
visible.$test(true),
);
Reactively sets a single CSS style property on an element. Supports both standard CSSStyleDeclaration properties and CSS custom properties (--*).
useStyleTrait(
el: HTMLElement,
prop: keyof CSSStyleDeclaration | `--${string}`,
val: (() => string | number | undefined) | (string | number | undefined),
...rest: (StateType<any> | Condition)[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target element |
prop | keyof CSSStyleDeclaration | \--${string}`` | The CSS property name. Use camelCase for standard properties (e.g. 'backgroundColor') or '--custom-prop' for custom properties. |
val | (() => string | number | undefined) | string | number | undefined | The CSS value. Pass a function for reactive evaluation. |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. The style is applied only when all Conditions are truthy. |
val (calls it if it's a function).--*): uses el.style.setProperty(prop, val).el.style[prop].rest so the trait re-runs on state changes.A cleanup function that unsubscribes from all State listeners.
// Token value (re-evaluates on theme change via $val)
trait.style('backgroundColor', surface_bg_primary.$val);
trait.style('padding', space_padding_md.$val);
// Conditional toggle — unconditional default + conditional override
// IMPORTANT: Never use two opposing $test conditions on the same property.
// Each conditional trait has its own saved-value slot, and opposing conditions
// will corrupt each other's snapshots, preventing the style from reverting.
trait.style('opacity', '0'); // default
trait.style('opacity', '1', visible.$test(true)); // override when true
// Custom CSS property
trait.style('--header-height', '64px');
// Conditional application
trait.style(
'display',
'none',
someState.$test((s) => !s.visible),
);
Reactively sets the value property of an <input> or <textarea> element. Pairs with useInputEventTrait for two-way data binding.
useInputValueTrait(
el: HTMLInputElement | HTMLTextAreaElement,
value: (() => string | number | undefined) | (string | number | undefined),
...rest: (StateType<any> | Condition)[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLInputElement | HTMLTextAreaElement | The target input or textarea element |
value | (() => string | number | undefined) | string | number | undefined | The value to set. Pass a function for reactive evaluation. |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. The trait re-evaluates on state changes and only applies when all Conditions are truthy. |
value (calls it if it's a function).el.value.rest so the trait re-runs on state changes.useInputEventTrait to create a reactive loop: state → input value → user types → event → update state → input value updates.A cleanup function that unsubscribes from all State listeners.
// Two-way binding pattern
tag.input(trait.inputValue(nameState.$val), trait.inputEvent('input', nameState.set));
// Conditional binding
tag.input(trait.inputValue(searchState.$val, isEditing.$test(true)));
Attaches a DOM event listener to an element. The listener is added or removed reactively based on Conditions and State changes.
useEventTrait(
el: HTMLElement,
evt: keyof GlobalEventHandlersEventMap,
cb: (evt?: GlobalEventHandlersEventMap[keyof GlobalEventHandlersEventMap]) => void,
...rest: (StateType<any> | Condition)[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target element |
evt | keyof GlobalEventHandlersEventMap | The event name (e.g. 'click', 'mouseenter', 'submit') |
cb | (evt?) => void | The event handler callback |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. The listener is attached only when all Conditions are truthy and detached when any becomes falsy. |
el.addEventListener(evt, cb).el.removeEventListener(evt, cb).rest so the trait re-evaluates on state changes.A cleanup function that removes the event listener and unsubscribes from all State listeners.
trait.event(
'click',
count.$reduce((prev) => prev + 1),
);
trait.event('submit', (e) => {
e.preventDefault();
save();
});
trait.event('mouseenter', showTooltip, enabled.$test(true));
Sets the inner content of an element by appending child elements or text nodes. Clears the element first, then appends children — making it safe for HTMLElement and SVGElement children (no serialization).
useInnerHTMLTrait(
el: HTMLElement,
children: Child | Child[] | (() => Child | Child[]),
...rest: (StateType<any> | Condition)[]
) => () => void
Where Child = string | number | HTMLElement | SVGElement | undefined | unknown.
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The parent element whose content will be set |
children | Child | Child[] | (() => Child | Child[]) | The child content. Can be a single value, an array, or a function returning either. HTMLElement/SVGElement children are appended directly; primitives are converted to text nodes. |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. The trait re-evaluates on state changes and only applies when all Conditions are truthy. |
el.innerHTML.children (calls it if it's a function).appendChild, and wraps primitives in text nodes.el.innerHTML to the stringified value.rest so the trait re-runs on state changes.A cleanup function that unsubscribes from all State listeners.
tag.div(
trait.innerHTML(
() => [tag.h1(trait.textContent('Hello')), tag.p(trait.textContent(message.$val))],
message,
),
);
tag.ul(
trait.innerHTML(() => items.val().map((item) => tag.li(trait.textContent(item.label))), items),
);
Reactively sets ARIA attributes and the role attribute on an element. When the value is undefined or conditions evaluate to false, the attribute is removed. Provides first-class accessibility support with typed ARIA property names.
useAriaTrait(
el: HTMLElement,
prop: 'role' | `aria-${string}`,
val: (() => string | number | boolean | undefined) | (string | number | boolean | undefined),
...rest: (StateType<any> | Condition)[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target element |
prop | 'role' | \aria-${string}`` | The ARIA attribute name (e.g. 'aria-label', 'aria-expanded', 'role') |
val | (() => string | number | boolean | undefined) | string | number | boolean | undefined | The attribute value. Pass a function for reactive evaluation. undefined removes the attribute. |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. The trait re-evaluates whenever a State publishes and only applies when all Conditions are truthy. |
val (calls it if it's a function).val is undefined, removes the attribute.el.setAttribute(prop, String(val)).rest so the trait re-runs on state changes.A cleanup function that unsubscribes from all State listeners.
// Static ARIA label
trait.aria('aria-label', 'Close dialog');
// Role assignment
trait.aria('role', 'navigation');
// Reactive expanded state
trait.aria(
'aria-expanded',
() => String(menuOpen.val()),
menuOpen,
);
// Conditional aria-hidden
trait.aria('aria-hidden', 'true', visible.$test(false));
trait.aria('aria-hidden', 'false', visible.$test(true));
// Reactive aria-live region
trait.aria('aria-live', 'polite');
trait.aria(
'aria-label',
() => `${notifications.val().length} new notifications`,
notifications,
);
// Aria-current for navigation
trait.aria(
'aria-current',
'page',
activeRoute.$test((r) => r === '/about'),
);
const expanded = State<boolean>(false);
tag.button(
trait.aria('aria-expanded', () => String(expanded.val()), expanded),
trait.aria('aria-controls', 'panel-1'),
trait.event('click', expanded.$reduce((v) => !v)),
trait.textContent(() => (expanded.val() ? 'Collapse' : 'Expand'), expanded),
);
tag.div(
trait.aria('role', 'status'),
trait.aria('aria-live', 'polite'),
trait.textContent(() => statusMessage.val(), statusMessage),
);
tag.div(
trait.aria('role', 'tabpanel'),
trait.aria('aria-labelledby', 'tab-1'),
trait.aria(
'aria-hidden',
() => String(activeTab.val() !== 'tab-1'),
activeTab,
),
);
| Feature | useAriaTrait | useAttributeTrait |
|---|---|---|
| Type safety | Typed to role and aria-* | Accepts any string |
| Intended use | Accessibility attributes | General HTML attributes |
| Behavior | Identical | Identical |
useAriaTrait is a semantic specialization — it constrains the prop parameter to valid ARIA attributes, making intent clearer and preventing accidental misuse.
useAttributeTraitundefined or failing conditions) is the correct way to "unset" ARIA propertiesaria-expanded must be set as string "true" or "false" — they are not boolean HTML attributesSets the class attribute of an HTML element reactively. Replaces the entire class string — does not toggle individual classes.
useClassNameTrait(
el: HTMLElement,
className: string | (() => string),
...rest: (StateType<any> | Condition)[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target element |
className | string | (() => string) | The class string to apply. Pass a function for reactive evaluation. |
...rest | (StateType<any> | Condition)[] | Optional State objects and/or Conditions. The trait re-evaluates whenever a State publishes and only applies when all Conditions are truthy. |
className (calls it if it's a function).el.setAttribute('class', ...).rest so the trait re-runs on state changes.A cleanup function that unsubscribes from all State listeners.
trait.className('btn btn-primary');
trait.className('tab active', isActive.$test(true));
trait.className('tab', isActive.$test(false));
Applies a CSS style property when a DOM event fires on the element. Unlike useStyleTrait, this trait does not subscribe to State objects — it runs the style application inside the event handler itself.
useStyleOnEventTrait(
el: HTMLElement,
evt: keyof HTMLElementEventMap,
prop: keyof CSSStyleDeclaration | `--${string}`,
val: (() => string | number | undefined) | (string | number | undefined),
...rest: Condition[]
) => () => void
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The target element |
evt | keyof HTMLElementEventMap | The event that triggers the style application (e.g. 'mouseenter', 'mouseleave', 'focus') |
prop | keyof CSSStyleDeclaration | \--${string}`` | The CSS property name (camelCase or --custom-prop) |
val | (() => string | number | undefined) | string | number | undefined | The CSS value. Pass a function for reactive evaluation at event time. |
...rest | Condition[] | Optional Conditions. The style is applied only when all Conditions are truthy at the time the event fires. |
evt on el.val, checks Conditions, and applies the style if all pass.--*): uses el.style.setProperty(prop, val).el.style[prop].A cleanup function that removes the event listener.
// Hover highlight pattern
trait.styleOnEvent('mouseenter', 'backgroundColor', action_bg_hover.$val);
trait.styleOnEvent('mouseleave', 'backgroundColor', action_bg_primary.$val);
// Focus ring
trait.styleOnEvent('focus', 'outline', () => `2px solid ${focus_border_primary.val()}`);
trait.styleOnEvent('blur', 'outline', 'none');
A reactive state hook that tracks the current URL, matching it against a set of defined routes and extracting params, query strings, and hash fragments.
/user/:id):param segments require a typed params objectpopstate and hashchange eventsimport { useUrlState } from '@linttrap/oem';
const url = useUrlState<{
'/': any;
'/about': any;
'/user/:id': any;
'/user/:id/post/:postId': any;
}>({
'/': () => '/',
'/about': () => '/about',
'/user/:id': ({ id }) => `/user/${id}`,
'/user/:id/post/:postId': ({ id, postId }) => `/user/${id}/post/${postId}`,
});
// Read the current URL state
const { matchedRoute, params, query, hash } = url.val();
// Subscribe to URL changes
url.sub(({ matchedRoute, params, query, hash }) => {
console.log('Route:', matchedRoute);
console.log('Params:', params);
console.log('Query:', query);
console.log('Hash:', hash);
});
function useUrlState<T extends Record<string, any>>(routes: {
[K in keyof T]: K extends string ? RouteHandler<K> : never;
}): StateType<UrlState<typeof routes>, {}>;
| Parameter | Type | Description |
|---|---|---|
routes | object | A map of route patterns to handler functions. Patterns use :param syntax for dynamic segments. |
Route handlers are type-inferred from the pattern string:
() => string:param): (params: { [K in ExtractRouteParams<Path>]: string }) => stringReturns a State<UrlState> containing:
| Property | Type | Description |
|---|---|---|
matchedRoute | string | The route pattern that matched the current pathname, or '' |
params | Record<string, string> | Named parameters extracted from the matched route |
query | Record<string, string> | Parsed query string parameters |
hash | string | The hash fragment (without the leading #) |
location | Location | The raw document.location object |
routes | Routes | The routes map passed to the hook |
document.location on initialization and returns the matched statepopstate events (browser back/forward navigation)hashchange events (hash fragment changes)/user/:id to regex and tests against the current pathnamematchedRoute is an empty string and params is an empty objectconst url = useUrlState<{
'/': any;
'/about': any;
'/contact': any;
}>({
'/': () => '/',
'/about': () => '/about',
'/contact': () => '/contact',
});
url.sub(({ matchedRoute }) => {
switch (matchedRoute) {
case '/':
renderHome();
break;
case '/about':
renderAbout();
break;
case '/contact':
renderContact();
break;
}
});
const url = useUrlState<{
'/user/:id': any;
}>({
'/user/:id': ({ id }) => `/user/${id}`,
});
// If current URL is /user/42
const { params } = url.val();
console.log(params.id); // "42"
const url = useUrlState<{
'/': any;
'/about': any;
}>({
'/': () => '/',
'/about': () => '/about',
});
// Create a condition to test the matched route
const isHome = url.$test((s) => s.matchedRoute === '/');
const isAbout = url.$test((s) => s.matchedRoute === '/about');
popstate, hashchange) are added globally and remain active for the lifetime of the page[\w-]+)URLSearchParams APIroutes object is included in the state value for programmatic navigation via handler functionsA reactive state hook that tracks whether the current viewport matches specified media query conditions.
import { useMediaQueryState } from '@linttrap/oem';
// Track mobile viewport (max 768px)
const isMobile = useMediaQueryState({
maxWidth: 768,
});
// Track tablet viewport (768px - 1024px)
const isTablet = useMediaQueryState({
minWidth: 768,
maxWidth: 1024,
});
// Track desktop viewport (min 1024px)
const isDesktop = useMediaQueryState({
minWidth: 1024,
});
// Track print media
const isPrint = useMediaQueryState({
type: 'print',
});
| Property | Type | Default | Description |
|---|---|---|---|
type | 'screen' | 'print' | 'all' | 'all' | Media type to match against |
minWidth | number | 0 | Minimum viewport width in pixels |
maxWidth | number | Infinity | Maximum viewport width in pixels |
Returns a State<boolean> that is true when the media query matches and false otherwise.
The state automatically:
// Define standard breakpoints
const isMobile = useMediaQueryState({ maxWidth: 639 });
const isTablet = useMediaQueryState({ minWidth: 640, maxWidth: 1023 });
const isDesktop = useMediaQueryState({ minWidth: 1024 });
const isMobile = useMediaQueryState({ maxWidth: 768 });
if (isMobile.val()) {
// Render mobile layout
} else {
// Render desktop layout
}
A small state hook for managing the current theme as a reactive State value.
'light' or 'dark'State object that can be observed or updatedimport { useThemeState } from '@linttrap/oem';
const theme = useThemeState('light');
// Read the current theme
const current = theme.val();
// Update the theme
theme.set('dark');
type Theme = 'light' | 'dark';
function useThemeState(theme: Theme): State<Theme>;
| Parameter | Type | Default | Description |
|---|---|---|---|
theme | Theme | — | Initial theme value ('light' or 'dark') |
Returns a State<Theme> representing the current theme.
set() is calledA reactive state hook that tracks whether the browser is currently online or offline.
navigator.onLine valueonline and offline window eventsimport { useOnlineState } from '@linttrap/oem';
const isOnline = useOnlineState();
// Read current connectivity
console.log(isOnline.val()); // true or false
// React to connectivity changes
isOnline.sub((online) => {
console.log(online ? 'Back online' : 'Gone offline');
});
function useOnlineState(): StateType<boolean, {}>;
None.
Returns a State<boolean> that is true when the browser is online and false when offline.
navigator.onLineonline window event and sets state to trueoffline window event and sets state to falseconst isOnline = useOnlineState();
// Show/hide an offline banner
trait.style('display', 'none', isOnline.$test(true));
trait.style('display', 'flex', isOnline.$test(false));
const isOnline = useOnlineState();
trait.event(
'click',
() => fetchData(),
isOnline.$test(true),
);
navigator.onLine API and online/offline window eventsA reactive state hook that tracks the current viewport dimensions.
import { useWindowSizeState } from '@linttrap/oem';
const windowSize = useWindowSizeState();
// Read current dimensions
const { width, height } = windowSize.val();
// Subscribe to size changes
windowSize.sub(({ width, height }) => {
console.log(`${width}x${height}`);
});
function useWindowSizeState(): StateType<WindowSize, {}>;
None.
Returns a State<WindowSize> containing:
| Property | Type | Description |
|---|---|---|
width | number | Current viewport width in pixels |
height | number | Current viewport height in pixels |
{ width: window.innerWidth, height: window.innerHeight }resize window eventconst windowSize = useWindowSizeState();
windowSize.sub(({ width, height }) => {
canvas.width = width;
canvas.height = height;
});
const windowSize = useWindowSizeState();
trait.style('gridTemplateColumns', () => {
const cols = Math.floor(windowSize.val().width / 300);
return `repeat(${cols}, 1fr)`;
}, windowSize);
const windowSize = useWindowSizeState();
trait.style('height', () => `${windowSize.val().height - 64}px`, windowSize);
| Feature | useWindowSizeState | useMediaQueryState |
|---|---|---|
| Return type | { width, height } | boolean |
| Use case | Computed layouts | Breakpoint conditions |
| Condition-friendly | Requires $test fn | Direct $test(true/false) |
Use useMediaQueryState for breakpoint-gated trait conditions. Use useWindowSizeState when you need raw pixel values for calculations.
useMediaQueryState insteadA derived state hook that selects between two token values based on the current theme.
import { useThemeState, useTokenState } from '@linttrap/oem';
const theme = useThemeState('light');
const primaryColor = useTokenState('#111827', '#f9fafb', theme);
// Read the current token value
const color = primaryColor.val();
// Switch theme and let token update
theme.set('dark');
function useTokenState<T>(lightVal: T, darkVal: T, themeState: StateType<Theme, {}>): State<T>;
| Parameter | Type | Default | Description |
|---|---|---|---|
lightVal | T | — | Token value for the light theme |
darkVal | T | — | Token value for the dark theme |
themeState | StateType<Theme, {}> | — | Theme state used to select the active token |
Returns a State<T> whose value updates when the theme changes.
lightVal or darkVal based on themeState.val()themeState and updates the token whenever the theme changesconst theme = useThemeState('light');
const textColor = useTokenState('#111', '#eee', theme);
const bgColor = useTokenState('#fff', '#0b0f1a', theme);
themeState existsthemeState is shared across tokens to keep updates consistentA reactive state hook that tracks whether an element is visible within the viewport (or a specified root element) using the Intersection Observer API.
IntersectionObserver optionsimport { useIntersectionObserverState } from '@linttrap/oem';
const section = document.getElementById('hero')!;
const visibility = useIntersectionObserverState(section);
// Check if element is visible
visibility.sub(({ isIntersecting }) => {
console.log(isIntersecting ? 'Visible' : 'Hidden');
});
function useIntersectionObserverState(
el: Element,
options?: IntersectionObserverInit,
): StateType<IntersectionObserverStateValue, {}>;
| Parameter | Type | Default | Description |
|---|---|---|---|
el | Element | — | The DOM element to observe |
options | IntersectionObserverInit | — | Standard IntersectionObserver options (root, rootMargin, threshold) |
Returns a State<IntersectionObserverStateValue> with the following shape:
| Property | Type | Description |
|---|---|---|
isIntersecting | boolean | Whether the element is currently intersecting |
intersectionRatio | number | Fraction of the element visible (0 to 1) |
boundingClientRect | DOMRectReadOnly | null | The element's bounding rect at observation time |
IntersectionObserver and immediately begins observing the target elementconst placeholder = document.getElementById('lazy-section')!;
const visibility = useIntersectionObserverState(placeholder, {
threshold: 0.1,
});
visibility.sub(({ isIntersecting }) => {
if (isIntersecting) loadContent();
});
const sections = ['intro', 'features', 'pricing'].map((id) => ({
id,
visibility: useIntersectionObserverState(document.getElementById(id)!, {
threshold: 0.5,
}),
}));
sections.forEach(({ id, visibility }) => {
visibility.sub(({ isIntersecting }) => {
if (isIntersecting) activeSection.set(id);
});
});
const el = document.getElementById('animate-me')!;
const visibility = useIntersectionObserverState(el, { threshold: 0.2 });
trait.style('opacity', '0');
trait.style(
'opacity',
'1',
visibility.$test((v) => v.isIntersecting),
);
trait.style(
'transition',
'opacity 0.3s ease',
);
threshold option to control when intersection callbacks fire (e.g., [0, 0.25, 0.5, 0.75, 1] for granular tracking)IntersectionObserver API — supported in all modern browsersA reactive state hook that increments a counter at a fixed interval. Provides custom methods to start, stop, and reset the timer.
$start, $stop, $reset for use in event handlersimport { useTimerState } from '@linttrap/oem';
// Tick every second, auto-starts
const timer = useTimerState(1000);
// Read current tick count
console.log(timer.val()); // 0, 1, 2, ...
// Control the timer
timer.stop();
timer.reset();
timer.start();
// Subscribe to ticks
timer.sub((count) => {
console.log('Tick:', count);
});
function useTimerState(
intervalMs: number,
options?: { autoStart?: boolean },
): StateType<number, { start; stop; reset }>;
| Parameter | Type | Default | Description |
|---|---|---|---|
intervalMs | number | — | Interval between ticks in milliseconds |
options | { autoStart?: boolean } | — | Configuration. autoStart defaults to true if omitted. |
Returns a State<number> with these additional custom methods:
| Method | Signature | Description |
|---|---|---|
start() | () => void | Starts the interval (no-op if already running) |
stop() | () => void | Stops the interval |
reset() | () => void | Stops the interval and resets the counter to 0 |
Each method also has a $-prefixed deferred version ($start, $stop, $reset) that returns () => void.
0autoStart is not explicitly false, starts the interval immediately1 and notifies all subscribersstart() is a no-op if the timer is already running (prevents double intervals)stop() clears the interval; start() can resume from the current countreset() clears the interval AND sets the counter back to 0const timer = useTimerState(30000); // every 30 seconds
timer.sub(() => {
saveDocument();
});
const DURATION = 60;
const timer = useTimerState(1000);
trait.textContent(() => `${DURATION - timer.val()}s remaining`);
const timer = useTimerState(5000, { autoStart: false });
timer.sub(() => fetchUpdates());
// Wire start/stop to buttons
trait.event('click', timer.$start());
trait.event('click', timer.$stop());
const idle = useTimerState(1000);
idle.sub((seconds) => {
if (seconds >= 300) logout();
});
// Reset on user activity
document.addEventListener('mousemove', () => idle.reset());
setInterval under the hoodintervalMsA reactive state hook for managing form values, validation errors, touched fields, and dirty state.
$setField, $touch, $validate, $reset for event wiringimport { useFormState } from '@linttrap/oem';
const form = useFormState(
{ email: '', password: '' },
{
email: (val) => (!val ? 'Required' : undefined),
password: (val) => (val.length < 8 ? 'Min 8 characters' : undefined),
},
);
// Set a field value
form.setField('email', 'user@example.com');
// Touch a field
form.touch('email');
// Validate all fields
const isValid = form.validate();
// Read state
const { values, errors, touched, dirty, valid } = form.val();
// Reset to initial
form.reset();
function useFormState<T extends Record<string, any>>(
initialValues: T,
validators?: Partial<Record<keyof T, (value: T[keyof T], values: T) => string | undefined>>,
): StateType<FormStateValue<T>, { setField; setError; touch; validate; reset }>;
| Parameter | Type | Default | Description |
|---|---|---|---|
initialValues | T | — | The initial form values |
validators | Partial<Record<keyof T, (value, values) => string | undefined>> | — | Optional validators per field. Return a string for errors, undefined for valid. |
Returns a State<FormStateValue<T>> with shape:
| Property | Type | Description |
|---|---|---|
values | T | Current field values |
errors | Partial<Record<keyof T, string>> | Validation error messages per field |
touched | Partial<Record<keyof T, boolean>> | Whether each field has been touched |
dirty | boolean | true if any field has been modified |
valid | boolean | true if there are no validation errors |
| Method | Signature | Description |
|---|---|---|
setField(field, value) | (keyof T, T[keyof T]) => void | Updates a field value and re-runs validation |
setError(field, error) | (keyof T, string | undefined) => void | Manually set or clear an error on a field |
touch(field) | (keyof T) => void | Marks a field as touched |
validate() | () => boolean | Validates all fields, touches all, returns validity |
reset() | () => void | Resets values, errors, touched, dirty, and valid |
Each method also has a $-prefixed deferred version.
initialValues, no errors, no touched fields, dirty: false, valid: truesetField: updates the field value, runs all validators, updates errors and valid, sets dirty: truetouch: marks the field as touched (useful for showing errors only after interaction)validate: runs all validators, marks all fields as touched, updates state, returns booleanreset: returns to the exact initial stateconst form = useFormState(
{ name: '', email: '', message: '' },
{
name: (v) => (!v ? 'Name is required' : undefined),
email: (v) => (!v.includes('@') ? 'Invalid email' : undefined),
message: (v) => (v.length < 10 ? 'Too short' : undefined),
},
);
trait.inputValue(() => form.val().values.email, form);
trait.inputEvent('input', (val) => form.setField('email', val), form);
trait.event('blur', form.$touch('email'));
trait.textContent(
() => {
const { errors, touched } = form.val();
return touched.email ? errors.email || '' : '';
},
form,
);
trait.event('click', () => {
if (form.validate()) {
submitForm(form.val().values);
}
});
const form = useFormState(
{ password: '', confirmPassword: '' },
{
confirmPassword: (val, values) =>
val !== values.password ? 'Passwords must match' : undefined,
},
);
setField callvalid flag reflects the result of the most recent validation runvalidate() touches all fields so error messages become visiblesetError manually after an async callimport { useThemeState, useTokenState } from '@linttrap/oem';
const theme = useThemeState('light');
const bg = useTokenState('#c7c7c7', '#222222', theme);
const fg = useTokenState('#222222', '#c7c7c7', theme);
const accent = useTokenState('#555555', '#999999', theme);
const app = tag.div(
trait.style('backgroundColor', bg.$val),
trait.style('color', fg.$val),
trait.style('transition', 'all 0.3s ease'),
tag.button(
trait.text('☀️ Light', theme.$test('dark')),
trait.text('🌙 Dark', theme.$test('light')),
trait.event('click', () => theme.reduce((t) => (t === 'dark' ? 'light' : 'dark'))),
trait.style('color', accent.$val),
),
);
const items = State<string[]>([]);
const input = State('');
const list = tag.div(
tag.div(
trait.style('display', 'flex'),
trait.style('gap', '8px'),
tag.input(
trait.inputValue(input.$val),
trait.inputEvent('input', input.$set),
trait.style('flex', '1'),
),
tag.button(
trait.text('Add'),
trait.event('click', () => {
items.reduce((prev) => [...prev, input.val()]);
input.set('');
}),
),
),
tag.ul(trait.innerHTML(() => items.val().map((item) => tag.li(trait.text(item))), items)),
);
const count = State(0);
const counter = tag.div(
trait.style('display', 'flex'),
trait.style('alignItems', 'center'),
trait.style('gap', '16px'),
tag.button(
trait.text('−'),
trait.event(
'click',
count.$reduce((n) => n - 1),
),
trait.style('fontSize', '24px'),
),
tag.span(
trait.text(count.$val),
trait.style('fontSize', '48px'),
trait.style('fontWeight', '700'),
trait.style('color', '#555555'),
),
tag.button(
trait.text('+'),
trait.event(
'click',
count.$reduce((n) => n + 1),
),
trait.style('fontSize', '24px'),
),
);
const isActive = State(false);
tag.div(
// Base styles — always applied
trait.style('padding', '16px'),
trait.style('borderRadius', '8px'),
trait.style('cursor', 'pointer'),
trait.style('transition', 'all 0.2s ease'),
// Conditional branches — never ternaries
trait.style('backgroundColor', '#555555', isActive.$test(true)),
trait.style('backgroundColor', '#c7c7c7', isActive.$test(false)),
trait.style('color', '#c7c7c7', isActive.$test(true)),
trait.style('color', '#888888', isActive.$test(false)),
trait.style('boxShadow', '0 0 20px rgba(80,80,80,0.3)', isActive.$test(true)),
trait.style('boxShadow', 'none', isActive.$test(false)), isActive.$test(false)),
trait.text('Active', isActive.$test(true)),
trait.text('Inactive', isActive.$test(false)),
trait.event('click', isActive.$reduce(v => !v)),
);