/* * This file belongs to Hoist, an application development toolkit * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) * * Copyright © 2026 Extremely Heavy Industries Inc. */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {div} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistProps, LayoutProps, StyleProps} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; import '@xh/hoist/desktop/register'; import {Icon} from '@xh/hoist/icon'; import {inputGroup} from '@xh/hoist/kit/blueprint'; import {getTestId, TEST_ID, withDefault} from '@xh/hoist/utils/js'; import {composeRefs, getLayoutProps} from '@xh/hoist/utils/react'; import type {Property} from 'csstype'; import {isEmpty} from 'lodash'; import {FocusEvent, KeyboardEventHandler, ReactElement, ReactNode, Ref} from 'react'; export interface TextInputProps extends HoistProps, HoistInputProps, LayoutProps, StyleProps { value?: string; /** * HTML `autocomplete` attribute to set on underlying element. * * Defaults to 'off' for fields of type text and 'new-password' for fields of type 'password' * to defeat browser auto-completion, which is typically not desired in Hoist applications. * Set to 'on' or a more specific autocomplete token to enable. * * @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls%3A-the-autocomplete-attribute * @see https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion */ autoComplete?: string; /** True to focus the control on render. */ autoFocus?: boolean; /** True to commit on every change/keystroke, default false. */ commitOnChange?: boolean; /** True to show a "clear" button as the right element. default false */ enableClear?: boolean; /** Ref handler that receives HTML element backing this component. */ inputRef?: Ref; /** Icon to display inline on the left side of the input. */ leftIcon?: ReactElement; /** Callback for normalized keydown event. */ onKeyDown?: (e: KeyboardEvent) => void; /** Text to display when control is empty. */ placeholder?: string; /** Element to display inline on the right side of the input. */ rightElement?: ReactNode; /** True to display with rounded caps. */ round?: boolean; /** True to select contents when control receives focus. */ selectOnFocus?: boolean; /** Alignment of entry text within control, default 'left'. */ textAlign?: Property.TextAlign; /** True to allow browser spell check, default false. */ spellCheck?: boolean; /** Underlying HTML element type. */ type?: 'text' | 'password'; } /** * A single-line text input with additional support for embedded icons/elements. */ export const [TextInput, textInput] = hoistCmp.withFactory({ displayName: 'TextInput', className: 'xh-text-input', render(props, ref) { return useHoistInputModel(cmp, props, ref, TextInputModel); } }); (TextInput as any).hasLayoutSupport = true; //----------------------- // Implementation //----------------------- export class TextInputModel extends HoistInputModel { override xhImpl = true; override get commitOnChange() { return withDefault(this.componentProps.commitOnChange, false); } onChange = ev => { let {value} = ev.target; if (value === '') value = null; this.noteValueChange(value); }; onKeyDown: KeyboardEventHandler = ev => { if (ev.key === 'Enter') this.doCommit(); this.componentProps.onKeyDown?.(ev); }; override onFocus = (ev: FocusEvent) => { const target = ev.target as HTMLElement; if (this.componentProps.selectOnFocus && target.nodeName === 'INPUT') { (target as HTMLInputElement).select(); } this.noteFocused(); }; } const cmp = hoistCmp.factory( ({model, className, ...props}, ref) => { const {width, flex, ...layoutProps} = getLayoutProps(props); const isClearable = !isEmpty(model.internalValue); return div({ item: inputGroup({ value: model.renderValue || '', autoComplete: withDefault( props.autoComplete, props.type === 'password' ? 'new-password' : 'off' ), autoFocus: props.autoFocus, disabled: props.disabled, inputRef: composeRefs(model.inputRef as Ref, props.inputRef), leftIcon: props.leftIcon, placeholder: props.placeholder, rightElement: (props.rightElement as ReactElement) || (props.enableClear && !props.disabled && isClearable ? clearButton() : null), round: withDefault(props.round, false), spellCheck: withDefault(props.spellCheck, false), tabIndex: props.tabIndex, type: props.type, id: props.id, style: { ...props.style, ...layoutProps, textAlign: withDefault(props.textAlign, 'left') }, [TEST_ID]: props.testId, onChange: model.onChange, onKeyDown: model.onKeyDown }), className, style: { width: withDefault(width, 200), flex: withDefault(flex, null) }, onBlur: model.onBlur, onFocus: model.onFocus, ref }); } ); const clearButton = hoistCmp.factory(({model}) => button({ icon: Icon.cross(), tabIndex: -1, minimal: true, testId: getTestId(model.componentProps, 'clear-btn'), onClick: () => { model.noteValueChange(null); model.doCommit(); model.focus(); } }) );