@@ -32,38 +32,49 @@ export interface EditorInputProps extends Omit<InputWrapperProps, "onChange">, V
3232 onSuggestionSelect ?: ( suggestion : InputSuggestion ) => void
3333 disabled ?: boolean
3434 readonly ?: boolean
35+ /** Render as a single-line input: Enter is blocked, newlines in pasted or external values become spaces. */
36+ singleLine ?: boolean
3537 onChange ?: ( value : string ) => void
3638 placeholder ?: string
3739}
3840
39- type CustomText = { text : string ; tokenRuleIndex ?: number ; tokenText ?: string ; tokenMatch ?: RegExpExecArray }
41+ type CustomText = { text : string ; tokenRuleIndex ?: number ; tokenText ?: string ; tokenMatch ?: RegExpExecArray }
4042
4143const textToSlate = ( text : string ) : Descendant [ ] =>
4244 ( text || "" ) . split ( "\n" ) . map ( line => ( { type : "paragraph" as const , children : [ { text : line } ] } ) )
4345
4446const slateToText = ( value : Descendant [ ] ) : string =>
4547 value . map ( n => SlateNode . string ( n ) ) . join ( "\n" )
4648
47- const EMPTY_TOKEN_RULES : EditorTokenRule [ ] = [ ]
48-
4949export const EditorInput : React . FC < EditorInputProps > = React . memo ( ( props ) => {
5050
5151 const {
52- title, right, left, rightType, leftType,
52+ title,
53+ right,
54+ left,
55+ rightType,
56+ leftType,
5357 description, wrapperComponent,
54- tokenRules = EMPTY_TOKEN_RULES ,
58+ tokenRules = [ ] ,
5559 suggestions,
5660 suggestionsEmptyState,
57- onSuggestionSelect : onSuggestionSelectProp ,
61+ onSuggestionSelect,
5862 formValidation, onChange,
59- disabled = false , readonly = false ,
63+ disabled = false ,
64+ readonly = false ,
65+ singleLine = false ,
6066 placeholder,
61- value : valueProp , initialValue : initialValueProp , defaultValue : defaultValueProp ,
62- required : _required ,
67+ value : valueProp ,
68+ initialValue,
69+ defaultValue,
70+ required,
6371 ...rest
6472 } = props
6573
66- const externalText = String ( valueProp ?? initialValueProp ?? defaultValueProp ?? "" )
74+ // In single-line mode, newlines never reach the document: they are stripped
75+ // here (external values), on Enter (keydown), and on paste.
76+ const rawText = String ( valueProp ?? initialValue ?? defaultValue ?? "" )
77+ const externalText = singleLine ? rawText . replace ( / \n / g, " " ) : rawText
6778
6879 const editor = useMemo ( ( ) => withHistory ( withReact ( createEditor ( ) ) ) , [ ] )
6980
@@ -72,11 +83,6 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
7283
7384 const prevTextRef = useRef ( externalText )
7485
75- // Read at onChange time (handleChange is defined before `open` / the position
76- // updater exist), so keep the latest values in refs.
77- const openRef = useRef ( false )
78- const updateTriggerPositionRef = useRef < ( ( ) => void ) | undefined > ( undefined )
79-
8086 // Sync external value resets (e.g. form reset)
8187 React . useEffect ( ( ) => {
8288 if ( externalText === prevTextRef . current ) return
@@ -91,18 +97,6 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
9197 } )
9298 } , [ externalText , editor ] )
9399
94- const setFormValue = formValidation ?. setValue
95- const handleChange = useCallback ( ( newValue : Descendant [ ] ) => {
96- // Slate fires onChange for selection changes too — keep the menu anchored
97- // to the caret as it moves while the suggestions are open.
98- if ( openRef . current ) updateTriggerPositionRef . current ?.( )
99- const text = slateToText ( newValue )
100- if ( text === prevTextRef . current ) return
101- prevTextRef . current = text
102- setFormValue ?.( text )
103- onChange ?.( text )
104- } , [ setFormValue , onChange ] )
105-
106100 const decorate = useCallback ( ( [ node , path ] : any ) => {
107101 const ranges : any [ ] = [ ]
108102 if ( ! Text . isText ( node ) || ! tokenRules . length ) return ranges
@@ -112,7 +106,13 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
112106 let match : RegExpExecArray | null
113107 while ( ( match = re . exec ( text ) ) !== null ) {
114108 if ( ! match [ 0 ] . length ) break
115- ranges . push ( { anchor : { path, offset : match . index } , focus : { path, offset : match . index + match [ 0 ] . length } , tokenRuleIndex : ri , tokenText : match [ 0 ] , tokenMatch : match } )
109+ ranges . push ( {
110+ anchor : { path, offset : match . index } ,
111+ focus : { path, offset : match . index + match [ 0 ] . length } ,
112+ tokenRuleIndex : ri ,
113+ tokenText : match [ 0 ] ,
114+ tokenMatch : match
115+ } )
116116 }
117117 } )
118118 return ranges
@@ -124,7 +124,11 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
124124 const rule = tokenRules [ l . tokenRuleIndex ]
125125 if ( rule ) {
126126 if ( rule . wrap ) return < span { ...attributes } > { rule . wrap ( l . tokenText ?? "" , children , l . tokenMatch ?? [ ] as any ) } </ span >
127- return < span { ...attributes } > < span style = { rule . style } className = { rule . className } > { children } </ span > </ span >
127+ return < span { ...attributes } >
128+ < span style = { rule . style } className = { rule . className } >
129+ { children }
130+ </ span >
131+ </ span >
128132 }
129133 }
130134 return < span { ...attributes } > { children } </ span >
@@ -134,7 +138,7 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
134138 const itemsHandleRef = useRef < InputSuggestionMenuContentItemsHandle > ( null )
135139 const editorContainerRef = useRef < HTMLDivElement > ( null )
136140 const triggerRef = useRef < HTMLButtonElement > ( null )
137- const blurTimer = useRef < ReturnType < typeof setTimeout > > ( )
141+ const blurTimer = useRef < ReturnType < typeof setTimeout > | undefined > ( undefined )
138142
139143 // Move the invisible Radix trigger to sit right behind the caret, so the
140144 // suggestion menu is anchored under the text cursor instead of the start of
@@ -148,7 +152,9 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
148152 let rect : DOMRect | null = null
149153 try {
150154 if ( editor . selection ) rect = ReactEditor . toDOMRange ( editor , editor . selection ) . getBoundingClientRect ( )
151- } catch { rect = null }
155+ } catch {
156+ rect = null
157+ }
152158 // Collapsed selections can report an empty rect — fall back to the editor start.
153159 if ( ! rect || ( ! rect . width && ! rect . height && ! rect . left && ! rect . top ) ) rect = container . getBoundingClientRect ( )
154160
@@ -159,22 +165,17 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
159165 btn . style . height = `${ rect . height || parseFloat ( getComputedStyle ( container ) . lineHeight ) || 16 } px`
160166 } , [ editor ] )
161167
162- openRef . current = open
163- updateTriggerPositionRef . current = updateTriggerPosition
164-
165- // A ref-like handle that focuses the Slate editor. The InputSuggestion menu
166- // uses this to keep the caret in the editor while the menu is open, so the
167- // menu never steals focus (which would otherwise blur the editor and make
168- // typing impossible).
169- const editorFocusRef = useMemo (
170- ( ) => ( { current : { focus : ( ) => { try { ReactEditor . focus ( editor ) } catch { /* not mounted yet */ } } } } ) ,
171- [ editor ]
172- ) as unknown as React . RefObject < HTMLInputElement >
173-
174- // When the menu is opened via keyboard we want to highlight an item straight
175- // away, but the items only mount once `open` flips — so remember the intent
176- // and apply it in an effect after the menu (and its handle) has mounted.
177- const pendingHighlightRef = useRef < null | "first" | "last" > ( null )
168+ const setFormValue = formValidation ?. setValue
169+ const handleChange = useCallback ( ( newValue : Descendant [ ] ) => {
170+ // Slate fires onChange for selection changes too — keep the menu anchored
171+ // to the caret as it moves while the suggestions are open.
172+ if ( open ) updateTriggerPosition ( )
173+ const text = slateToText ( newValue )
174+ if ( text === prevTextRef . current ) return
175+ prevTextRef . current = text
176+ setFormValue ?.( text )
177+ onChange ?.( text )
178+ } , [ open , updateTriggerPosition , setFormValue , onChange ] )
178179
179180 const openMenu = useCallback ( ( ) => {
180181 if ( blurTimer . current ) clearTimeout ( blurTimer . current )
@@ -185,27 +186,8 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
185186 } , [ suggestions , updateTriggerPosition ] )
186187
187188 React . useEffect ( ( ) => {
188- if ( ! open ) {
189- pendingHighlightRef . current = null
190- return
191- }
192- // Correct the anchor position once the menu has actually mounted.
189+ if ( ! open ) return
193190 updateTriggerPosition ( )
194- const highlight = pendingHighlightRef . current
195- if ( ! highlight ) return
196- let raf = 0
197- const apply = ( ) => {
198- const handle = itemsHandleRef . current
199- if ( ! handle ) {
200- raf = requestAnimationFrame ( apply )
201- return
202- }
203- if ( highlight === "first" ) handle . focusFirstItem ( )
204- else handle . focusLastItem ( )
205- pendingHighlightRef . current = null
206- }
207- apply ( )
208- return ( ) => { if ( raf ) cancelAnimationFrame ( raf ) }
209191 } , [ open ] )
210192
211193 // Defer closing so a transient blur (e.g. Radix moving focus on open, or a
@@ -216,6 +198,9 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
216198 } , [ ] )
217199
218200 const handleKeyDown = useCallback ( ( e : React . KeyboardEvent ) => {
201+ // No line breaks in single-line mode (menu selection below still works —
202+ // it calls preventDefault itself).
203+ if ( singleLine && e . key === "Enter" ) e . preventDefault ( )
219204 if ( ! suggestions ) return
220205 const handle = itemsHandleRef . current
221206
@@ -224,12 +209,8 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
224209 // on macOS it may need the input-source switch shortcut disabled.
225210 if ( e . ctrlKey && e . code === "Space" ) {
226211 e . preventDefault ( )
227- if ( ! open ) {
228- pendingHighlightRef . current = "first"
229- openMenu ( )
230- } else {
231- handle ?. focusFirstItem ( )
232- }
212+ if ( ! open ) openMenu ( )
213+ else handle ?. focusFirstItem ( )
233214 return
234215 }
235216
@@ -251,20 +232,30 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
251232 e . preventDefault ( )
252233 setOpen ( false )
253234 }
254- } , [ suggestions , open , openMenu ] )
235+ } , [ suggestions , open , openMenu , singleLine ] )
255236
256237
257238 const editorContent = (
258239 < div { ...mergeComponentProps ( "editor-input" , rest ) } ref = { editorContainerRef } >
259240 < Editable
260- className = "input-wrapper__control"
241+ className = { singleLine ? "input-wrapper__control input-wrapper__control--single-line" : "input-wrapper__control" }
261242 decorate = { decorate }
262243 renderLeaf = { renderLeaf }
263244 readOnly = { disabled || readonly }
264245 spellCheck = { false }
265246 placeholder = { placeholder }
266247 onBlur = { scheduleClose }
248+ // A transient blur (focus bounced back by the menu) must not
249+ // close the menu — cancel the pending close on refocus.
250+ onFocus = { ( ) => {
251+ if ( blurTimer . current ) clearTimeout ( blurTimer . current )
252+ } }
267253 onKeyDown = { handleKeyDown }
254+ onPaste = { singleLine ? ( e ) => {
255+ // Like a native <input>: paste plain text with newlines as spaces.
256+ e . preventDefault ( )
257+ editor . insertText ( e . clipboardData . getData ( "text/plain" ) . replace ( / \r ? \n / g, " " ) )
258+ } : undefined }
268259 />
269260 </ div >
270261 )
@@ -277,7 +268,10 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
277268 rightType = { rightType } leftType = { leftType }
278269 formValidation = { formValidation }
279270 wrapperComponent = { suggestions
280- ? { ...( wrapperComponent ?? { } ) , style : { position : "relative" as const , ...( wrapperComponent as any ) ?. style } }
271+ ? {
272+ ...( wrapperComponent ?? { } ) ,
273+ style : { position : "relative" as const , ...( wrapperComponent as any ) ?. style }
274+ }
281275 : wrapperComponent
282276 }
283277 >
@@ -291,13 +285,12 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
291285 tabIndex = { - 1 }
292286 aria-hidden
293287 onMouseDown = { ( e ) => e . preventDefault ( ) }
294- style = { { position : "absolute" , left : 0 , top : 0 , width : 0 , height : 0 , opacity : 0 , pointerEvents : "none" } }
288+ className = "editor-input__menu-anchor"
295289 />
296290 </ MenuTrigger >
297291 < MenuPortal >
298292 < InputSuggestionMenuContent
299293 color = "primary"
300- inputRef = { editorFocusRef }
301294 onInteractOutside = { ( e ) => {
302295 // The Slate editor is a contenteditable <div>, not an
303296 // <input>, so keep the menu open while the user clicks
@@ -310,9 +303,8 @@ export const EditorInput: React.FC<EditorInputProps> = React.memo((props) => {
310303 < InputSuggestionMenuContentItems
311304 ref = { itemsHandleRef }
312305 suggestions = { suggestions }
313- inputRef = { editorFocusRef }
314306 onSuggestionSelect = { ( s ) => {
315- onSuggestionSelectProp ?.( s )
307+ onSuggestionSelect ?.( s )
316308 setOpen ( false )
317309 } }
318310 />
0 commit comments