@@ -6,7 +6,17 @@ import { IconCheckmark, IconExclamationMark } from '../Icons';
66
77export type TextInputVariant = 'outline' | 'ghost' ;
88
9+ /** Where the active field message (error, success, or neutral) sits relative to the bordered control */
10+ export type TextInputFieldMessagePlacement = 'outside' | 'inside' ;
11+
912export type TextInputProps = Omit < ComponentProps < 'input' > , 'className' > & {
13+ /** Root class name */
14+ className ?: string ;
15+ /**
16+ * `outside` (default): message below the bordered wrapper.
17+ * `inside`: message under the value row, inside the border (error, success, or neutral).
18+ */
19+ fieldMessagePlacement ?: TextInputFieldMessagePlacement ;
1020 /** Optional label above the input */
1121 label ?: string ;
1222 /** Optional leading content (e.g. icon) inside the input area */
@@ -17,20 +27,109 @@ export type TextInputProps = Omit<ComponentProps<'input'>, 'className'> & {
1727 trailingText ?: string ;
1828 /** Neutral/helper message below the input (no icon) */
1929 message ?: ReactNode ;
20- /** Error message below the input ; shown when error is true, with errorMessageIcon */
30+ /** Error message; shown when ` error` is true, with ` errorMessageIcon` */
2131 errorMessage ?: ReactNode ;
22- /** Icon shown before error message (default: IconExclamationMark ) */
32+ /** Icon before error text (default: exclamation ) */
2333 errorMessageIcon ?: ReactNode ;
24- /** Success message below the input; shown when provided, with successMessageIcon */
34+ /** Success message below the input */
2535 successMessage ?: ReactNode ;
26- /** Icon shown before success message (default: IconCheckmark ) */
36+ /** Icon before success text (default: checkmark ) */
2737 successMessageIcon ?: ReactNode ;
28- /** When true, shows error border and error message styling */
38+ /** When true, error border and error styling */
2939 error ?: boolean ;
30- /** Visual variant: outline = border always visible, ghost = border only on focus */
40+ /** ` outline` = border always; ` ghost` = border on focus */
3141 variant ?: TextInputVariant ;
32- /** Optional class name for the root wrapper */
33- className ?: string ;
42+ } ;
43+
44+ type TextInputIconMessageLineProps = {
45+ icon : ReactNode ;
46+ text : ReactNode ;
47+ } ;
48+
49+ const TextInputIconMessageLine = ( { icon, text } : TextInputIconMessageLineProps ) => (
50+ < >
51+ < span aria-hidden className = 'str-chat__form-text-input__message-icon' >
52+ { icon }
53+ </ span >
54+ < span className = 'str-chat__form-text-input__message-text' > { text } </ span >
55+ </ >
56+ ) ;
57+
58+ /** At most one of error / success / neutral is shown under the field */
59+ type TextInputFieldMessageProps =
60+ | {
61+ kind : 'error' ;
62+ id ?: string ;
63+ insidePlacement : boolean ;
64+ errorMessageIcon ?: ReactNode ;
65+ text : ReactNode ;
66+ }
67+ | {
68+ kind : 'success' ;
69+ id ?: string ;
70+ insidePlacement : boolean ;
71+ successMessageIcon ?: ReactNode ;
72+ text : ReactNode ;
73+ }
74+ | {
75+ kind : 'neutral' ;
76+ id ?: string ;
77+ insidePlacement : boolean ;
78+ text : ReactNode ;
79+ } ;
80+
81+ const TextInputFieldMessage = ( props : TextInputFieldMessageProps ) => {
82+ if ( props . kind === 'neutral' ) {
83+ return (
84+ < div
85+ className = { clsx (
86+ 'str-chat__form-text-input__message' ,
87+ props . insidePlacement &&
88+ 'str-chat__form-text-input__message--field-message-inside' ,
89+ ) }
90+ id = { props . id }
91+ >
92+ { props . text }
93+ </ div >
94+ ) ;
95+ } else if ( props . kind === 'success' ) {
96+ return (
97+ < div
98+ className = { clsx (
99+ 'str-chat__form-text-input__message' ,
100+ 'str-chat__form-text-input__message--success' ,
101+ props . insidePlacement &&
102+ 'str-chat__form-text-input__message--field-message-inside' ,
103+ ) }
104+ id = { props . id }
105+ >
106+ < TextInputIconMessageLine
107+ icon = { props . successMessageIcon ?? < IconCheckmark /> }
108+ text = { props . text }
109+ />
110+ </ div >
111+ ) ;
112+ } else if ( props . kind === 'error' ) {
113+ return (
114+ < div
115+ className = { clsx (
116+ 'str-chat__form-text-input__message' ,
117+ 'str-chat__form-field-error' ,
118+ props . insidePlacement &&
119+ 'str-chat__form-text-input__message--field-message-inside' ,
120+ ) }
121+ id = { props . id }
122+ role = 'alert'
123+ >
124+ < TextInputIconMessageLine
125+ icon = { props . errorMessageIcon ?? < IconExclamationMark /> }
126+ text = { props . text }
127+ />
128+ </ div >
129+ ) ;
130+ }
131+
132+ return null ;
34133} ;
35134
36135export const TextInput = forwardRef < HTMLInputElement , TextInputProps > ( function TextInput (
@@ -40,6 +139,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
40139 error = false ,
41140 errorMessage,
42141 errorMessageIcon,
142+ fieldMessagePlacement = 'outside' ,
43143 id : idProp ,
44144 label,
45145 leading,
@@ -53,93 +153,99 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
53153 } ,
54154 ref ,
55155) {
56- const generatedId = useStableId ( ) ;
57- const id = idProp ?? generatedId ;
156+ const autoId = useStableId ( ) ;
157+ const id = idProp ?? autoId ;
58158
59- const displayError = error && ( errorMessage != null || message != null ) ;
60- const displaySuccess = successMessage != null ;
61- const displayNeutralMessage = message != null && ! error ;
62- const displayMessage = displayError || displaySuccess || displayNeutralMessage ;
159+ const hasError = error && ( errorMessage != null || message != null ) ;
160+ const showSuccess = ! hasError && successMessage != null ;
161+ const showNeutral = ! hasError && ! showSuccess && message != null ;
162+ const hasFeedback = hasError || showSuccess || showNeutral ;
163+ const messageInside = fieldMessagePlacement === 'inside' && hasFeedback ;
63164
64- const messageId = displayMessage ? `${ id } -message` : undefined ;
165+ const messageId = hasError
166+ ? `${ id } -field-error`
167+ : showSuccess || showNeutral
168+ ? `${ id } -message`
169+ : undefined ;
170+ const describedBy = messageId ;
65171
66- const messageContent = displayError ? (
67- < >
68- < span aria-hidden className = 'str-chat__form-text-input__message-icon' >
69- { errorMessageIcon ?? < IconExclamationMark /> }
70- </ span >
71- { errorMessage ?? message }
72- </ >
73- ) : displaySuccess ? (
74- < >
75- < span aria-hidden className = 'str-chat__form-text-input__message-icon' >
76- { successMessageIcon ?? < IconCheckmark /> }
77- </ span >
78- { successMessage }
79- </ >
80- ) : displayNeutralMessage ? (
81- ( message as ReactNode )
172+ const fieldMessage = hasError ? (
173+ < TextInputFieldMessage
174+ errorMessageIcon = { errorMessageIcon }
175+ id = { messageId }
176+ insidePlacement = { messageInside }
177+ kind = 'error'
178+ text = { errorMessage ?? message }
179+ />
180+ ) : showSuccess ? (
181+ < TextInputFieldMessage
182+ id = { messageId }
183+ insidePlacement = { messageInside }
184+ kind = 'success'
185+ successMessageIcon = { successMessageIcon }
186+ text = { successMessage }
187+ />
188+ ) : showNeutral ? (
189+ < TextInputFieldMessage
190+ id = { messageId }
191+ insidePlacement = { messageInside }
192+ kind = 'neutral'
193+ text = { message }
194+ />
82195 ) : null ;
83196
84197 return (
85198 < div
86199 className = { clsx (
87200 'str-chat__form-text-input' ,
88201 error && 'str-chat__form-text-input--error' ,
89- displaySuccess && 'str-chat__form-text-input--success' ,
202+ showSuccess && 'str-chat__form-text-input--success' ,
90203 disabled && 'str-chat__form-text-input--disabled' ,
204+ messageInside && 'str-chat__form-text-input--field-message-inside' ,
91205 className ,
92206 ) }
93207 >
94- { ! ! label && (
208+ { label ? (
95209 < label className = 'str-chat__form-text-input__label' htmlFor = { id } >
96210 { label }
97211 </ label >
98- ) }
212+ ) : null }
99213 < div
100214 className = { clsx (
101215 'str-chat__form-text-input__wrapper' ,
102216 `str-chat__form-text-input__wrapper--${ variant } ` ,
217+ messageInside && 'str-chat__form-text-input__wrapper--field-message-inside' ,
103218 ) }
104219 >
105- { ! ! leading && (
106- < span aria-hidden className = 'str-chat__form-text-input__leading' >
107- { leading }
108- </ span >
109- ) }
110- < input
111- aria-describedby = { messageId }
112- aria-invalid = { error }
113- className = 'str-chat__form-text-input__input'
114- disabled = { disabled }
115- id = { id }
116- ref = { ref }
117- { ...inputProps }
118- />
119- { trailingText != null && (
120- < span aria-hidden className = 'str-chat__form-text-input__suffix' >
121- { trailingText }
122- </ span >
123- ) }
124- { ! ! trailing && (
125- < span aria-hidden className = 'str-chat__form-text-input__trailing' >
126- { trailing }
127- </ span >
128- ) }
129- </ div >
130- { messageContent != null && (
131- < div
132- className = { clsx (
133- 'str-chat__form-text-input__message' ,
134- displayError && 'str-chat__form-field-error' ,
135- displaySuccess && 'str-chat__form-text-input__message--success' ,
136- ) }
137- id = { messageId }
138- role = { error ? 'alert' : undefined }
139- >
140- { messageContent }
220+ < div className = 'str-chat__form-text-input__control-row' >
221+ { leading ? (
222+ < span aria-hidden className = 'str-chat__form-text-input__leading' >
223+ { leading }
224+ </ span >
225+ ) : null }
226+ < input
227+ aria-describedby = { describedBy }
228+ aria-invalid = { error }
229+ className = 'str-chat__form-text-input__input'
230+ disabled = { disabled }
231+ id = { id }
232+ ref = { ref }
233+ { ...inputProps }
234+ />
235+ { trailingText != null ? (
236+ < span aria-hidden className = 'str-chat__form-text-input__suffix' >
237+ { trailingText }
238+ </ span >
239+ ) : null }
240+ { trailing ? (
241+ < span aria-hidden className = 'str-chat__form-text-input__trailing' >
242+ { trailing }
243+ </ span >
244+ ) : null }
141245 </ div >
142- ) }
246+ { messageInside ? fieldMessage : null }
247+ </ div >
248+ { messageInside ? null : fieldMessage }
143249 </ div >
144250 ) ;
145251} ) ;
0 commit comments