Skip to content

Latest commit

Β 

History

History
322 lines (254 loc) Β· 11.9 KB

File metadata and controls

322 lines (254 loc) Β· 11.9 KB

Input Package

Overview

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.

Architecture

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

HoistInputModel

The local model powering input components. Manages value conversion, commit lifecycle, and focus state.

Key Concepts

Bound vs Controlled Mode:

  • Bound mode: Input reads/writes to a model property via model and bind props
  • Controlled mode: Input uses value prop 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:

  • onChange fires on every value change (typing, selection)
  • onCommit fires 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
})

Value Conversion

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() ?? '';
    }
}

Whitespace Trimming

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.

Focus Management

// 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;

DOM Access

// Get the root DOM element
const domElement = inputModel.domEl;

// Get the actual <input> or <textarea> element
const inputElement = inputModel.inputEl;

HoistInputProps

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

Extra DOM Attributes with domAttrs

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: TextInput and NumberInput on mobile render Onsen's <ons-input> custom element, and SearchInput renders <ons-search-input>. Both mirror only a fixed allowlist of attributes onto the real <input> they create. Attributes supplied via domAttrs are applied to the <ons-input> wrapper, not to the inner <input>. This matches where testId lands today. Mobile TextArea is unaffected - it renders a native <textarea>.

Password Managers

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.

Integration with Forms

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
        })
    ]
})

Validation Display

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 disabled

Validation 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.

Building Custom Inputs

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
        })
    });
});

Key Implementation Points

  1. Use useHoistInputModel hook - Handles model creation, ref forwarding, and CSS class composition
  2. Pass className to wrapper - useHoistInputModel composes validation/disabled classes into this
  3. Place ref on outer element - The component ref goes on the wrapper div
  4. Place model.inputRef on <input> - For focus/select support on the actual input element
  5. Wire model.onFocus and model.onBlur - Required for commit-on-blur and validation display
  6. Call model.noteValueChange() - On user input, triggers onChange and potential commit
  7. Use model.renderValue - Returns appropriate value for display

Related Packages

  • /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