The /cmp/input/ package provides the base classes and interfaces for Hoist input components.
These abstractions define the common behavior for all form inputs across platforms, including
value binding, change/commit lifecycle, focus management, and validation display.
Platform-specific input implementations in /desktop/cmp/input/ and /mobile/cmp/input/ extend
these base classes to provide concrete UI components like TextInput, Select, DateInput, etc.
HoistInputProps (interface)
βββ bind: string # Property name on model to bind to
βββ value: any # Direct value (alternative to binding)
βββ disabled: boolean # Disable user interaction
βββ onChange: callback # Called on every value change
βββ onCommit: callback # Called when value is committed
βββ tabIndex: number # Focus order
HoistInputModel (class)
βββ hasFocus: boolean # Is input focused?
βββ renderValue: any # Value to render (computed)
βββ externalValue: any # Value from bound model or props
βββ internalValue: any # Cached internal representation
βββ commitOnChange: bool # Commit immediately on change?
βββ trimWhitespace: bool # Trim leading/trailing whitespace?
βββ Methods:
β βββ focus(), blur(), select()
β βββ noteValueChange(), doCommit()
β βββ toExternal(), toInternal()
β βββ noteBlurred(), noteFocused()
βββ DOM access:
βββ domEl: HTMLElement
βββ inputEl: HTMLInputElement
The local model powering input components. Manages value conversion, commit lifecycle, and focus state.
Bound vs Controlled Mode:
- Bound mode: Input reads/writes to a model property via
modelandbindprops - Controlled mode: Input uses
valueprop directly
// Bound mode - connects to FieldModel via FormField
formField({field: 'email', item: textInput()})
// Also bound mode - direct binding to any HoistModel
textInput({model: myModel, bind: 'searchQuery'})
// Controlled mode - explicit value management
textInput({
value: model.query,
onChange: (v) => model.setQuery(v)
})Change vs Commit:
onChangefires on every value change (typing, selection)onCommitfires when user completes a discrete edit (blur, enter, selection)- For model binding, values are written on commit, not on every change
- Some inputs (checkbox, switch, select) inherently commit on every change - there's no way to change without committing
// For text inputs, commit happens on blur or Enter
textInput({
bind: 'name',
model: myModel,
onChange: (v) => console.log('typing:', v), // Fires on each keystroke
onCommit: (v) => console.log('committed:', v) // Fires on blur/Enter
})
// This can be controlled via commitOnChange prop where supported
textInput({
commitOnChange: true // Commit immediately on change
})Inputs can convert between internal and external representations:
// Simplified example - NumberInput converts string input to numbers
// (Actual Hoist implementation handles additional formatting, precision, etc.)
class NumberInputModel extends HoistInputModel {
toExternal(internal: string): number {
return parseFloat(internal) || null;
}
toInternal(external: number): string {
return external?.toString() ?? '';
}
}Single-line text inputs - TextInput (desktop + mobile) and mobile SearchInput - trim leading
and trailing whitespace from their value by default. Leading/trailing whitespace is essentially
always unintentional in a single-line field, and it is invisible in the UI, so it tends to surface
only as a confusing validation failure - e.g. a pasted email address that fails the anchored
validEmail rule for no apparent reason.
Trimming happens as the internal value is converted to its external form, so it applies to the
value flushed to any bound model and to the values passed to onChange / onCommit. It catches
whitespace from typing, pasting, autofill, and IME input alike. The user sees exactly what they
type while the control has focus; any stray whitespace drops from the display when the value is
committed on blur or . A TextInput whose value trims away to nothing commits null, the
same as one the user has cleared.
// Commits 'user@example.com' - the pasted padding never reaches the model.
textInput({bind: 'email'})
// Set false to preserve whitespace exactly as entered.
textInput({bind: 'rawToken', trimWhitespace: false})TextInput does not trim by default when type: 'password', as passwords can legitimately
carry leading or trailing whitespace. Pass trimWhitespace: true to opt such a field in.
Multi-line and free-text controls - TextArea, CodeInput, JsonInput - never trim and do not
accept the prop, since whitespace can be meaningful there. Custom inputs can opt in by overriding
the HoistInputModel.trimWhitespace getter.
// Access from component ref
const inputRef = useRef<HoistInputModel>();
// Later...
inputRef.current.focus();
inputRef.current.blur();
inputRef.current.select(); // For text inputs
// Check focus state
inputRef.current.hasFocus;// Get the root DOM element
const domElement = inputModel.domEl;
// Get the actual <input> or <textarea> element
const inputElement = inputModel.inputEl;The common props interface extended by all input components.
| Prop | Type | Description |
|---|---|---|
bind |
string |
Model property name to bind to |
value |
any |
Direct value (alternative to binding) |
disabled |
boolean |
Disable user interaction |
onChange |
(value, oldValue) => void |
Called on value changes |
onCommit |
(value, oldValue) => void |
Called when value is committed |
tabIndex |
number |
Tab order for focus (-1 to skip) |
id |
string |
DOM ID for the input element |
testId |
string |
Emits data-testid on the input's primary DOM element |
domAttrs |
data-* / aria-* / role keys |
Extra HTML attributes for that same element |
Input components pass an explicit, named set of props to the element they render, so any other
attribute is dropped. Use domAttrs to apply data-* or aria-* attributes that Hoist does not
model with a dedicated prop.
textInput({
bind: 'email',
domAttrs: {'data-analytics-id': 'signup-email', 'aria-describedby': 'email-help'}
});Keys are constrained by type to data-* and aria-* attributes, plus role, so the prop cannot
reach attributes the component manages itself.
Mobile caveat:
TextInputandNumberInputon mobile render Onsen's<ons-input>custom element, andSearchInputrenders<ons-search-input>. Both mirror only a fixed allowlist of attributes onto the real<input>they create. Attributes supplied viadomAttrsare applied to the<ons-input>wrapper, not to the inner<input>. This matches wheretestIdlands today. MobileTextAreais unaffected - it renders a native<textarea>.
Password manager extensions flag any field that looks like a username, email, or password and
offer a saved-login prompt, whether or not the field has anything to do with signing in. Note that
autocomplete="off" does not stop them - they have ignored it on login-like fields for years.
TextInput, TextArea, and NumberInput therefore opt out by default, applying the vendor
attributes for 1Password, LastPass, and Bitwarden. Set enablePasswordManagers: true on genuine
credential fields, as Hoist's own LoginPanel does:
textInput({bind: 'password', type: 'password', autoComplete: 'current-password',
enablePasswordManagers: true});The attributes are applied during render, never in an effect - 1Password caches its assessment of a field on first focus, so they must already be present. The mobile caveat above applies here too.
HoistInputModel integrates with the form system:
// When used inside FormField, inputs automatically:
// 1. Read/write from the associated FieldModel
// 2. Trigger display of validation errors (by wrapping FormField) on blur
// 3. Inherit disabled/readonly state from the form
form({
model: formModel,
items: [
formField({
field: 'email', // Connects to formModel.fields.email
item: textInput() // textInput gets model/bind props automatically
})
]
})When bound to a FieldModel, inputs display validation states:
// CSS classes applied based on validation:
// - xh-input--error: Field has error severity
// - xh-input--warning: Field has warning severity
// - xh-input--info: Field has info severity
// - xh-input--invalid: Alias for error (backwards compat)
// - xh-input-disabled: Input is disabledValidation is displayed after:
- Field is blurred (via
noteBlurred()) - Form validation is triggered with
display: true - Field value becomes dirty
This deferred display prevents forms with e.g. many required fields from rendering initially with numerous red invalid indicators before the user has had a chance to interact.
To create a custom input component:
import {hoistCmp} from '@xh/hoist/core';
import {HoistInputModel, useHoistInputModel} from '@xh/hoist/cmp/input';
import {div} from '@xh/hoist/cmp/layout';
// 1. Extend HoistInputModel - can be minimal if no custom behavior needed
class MyInputModel extends HoistInputModel {
// Override for inputs that should commit on every change (e.g., checkbox, select)
override get commitOnChange(): boolean {
return true;
}
// Optional: convert between internal (UI) and external (model) representations
// override toExternal(internal: string): MyType { return parseMyType(internal); }
// override toInternal(external: MyType): string { return formatMyType(external); }
}
// 2. Create the public component with hoistCmp.withFactory
export const [MyInput, myInput] = hoistCmp.withFactory({
displayName: 'MyInput',
className: 'xh-my-input',
render(props, ref) {
return useHoistInputModel(cmp, props, ref, MyInputModel);
}
});
// 3. Internal implementation component
const cmp = hoistCmp.factory<MyInputModel>(({model, className, ...props}, ref) => {
return div({
className,
ref, // Outer ref on wrapper
onFocus: model.onFocus,
onBlur: model.onBlur,
item: input({
ref: model.inputRef, // inputRef on actual <input>
value: model.renderValue ?? '',
onChange: e => model.noteValueChange(e.target.value),
disabled: props.disabled
})
});
});- Use
useHoistInputModelhook - Handles model creation, ref forwarding, and CSS class composition - Pass
classNameto wrapper -useHoistInputModelcomposes validation/disabled classes into this - Place
refon outer element - The component ref goes on the wrapper div - Place
model.inputRefon<input>- For focus/select support on the actual input element - Wire
model.onFocusandmodel.onBlur- Required for commit-on-blur and validation display - Call
model.noteValueChange()- On user input, triggers onChange and potential commit - Use
model.renderValue- Returns appropriate value for display
/cmp/form/- Form and FieldModel that inputs bind to/desktop/cmp/input/- Desktop input implementations/mobile/cmp/input/- Mobile input implementations/desktop/cmp/form/- Desktop FormField component/mobile/cmp/form/- Mobile FormField component