@@ -46,6 +46,26 @@ const FIELDS = [
4646] ;
4747
4848describe ( "FlatTextLayerList" , ( ) => {
49+ it ( "falls back to a numbered label per index for empty fields, not a bare 'Text'" , ( ) => {
50+ const emptyFields = [
51+ { ...FIELDS [ 0 ] , value : "" } ,
52+ { ...FIELDS [ 1 ] , value : "" } ,
53+ ] ;
54+ const { host, root } = renderInto (
55+ < FlatTextLayerList
56+ fields = { emptyFields as never }
57+ activeFieldKey = "a"
58+ styles = { { } }
59+ onSelect = { vi . fn ( ) }
60+ onAdd = { vi . fn ( ) }
61+ onRemove = { vi . fn ( ) }
62+ /> ,
63+ ) ;
64+ expect ( host . textContent ) . toContain ( "Text 1" ) ;
65+ expect ( host . textContent ) . toContain ( "Text 2" ) ;
66+ act ( ( ) => root . unmount ( ) ) ;
67+ } ) ;
68+
4969 it ( "lists every field, highlights the active one, and fires onSelect/onAdd/onRemove" , ( ) => {
5070 const onSelect = vi . fn ( ) ;
5171 const onAdd = vi . fn ( ) ;
@@ -139,6 +159,114 @@ function makeMultiFieldElement(): DomEditSelection {
139159 } as DomEditSelection ;
140160}
141161
162+ function makeSingleFieldElement ( overrides : Partial < DomEditTextField > = { } ) : DomEditSelection {
163+ const base = makeMultiFieldElement ( ) ;
164+ return {
165+ ...base ,
166+ textFields : [
167+ {
168+ key : "a" ,
169+ label : "Text" ,
170+ value : "Headline" ,
171+ tagName : "div" ,
172+ attributes : [ ] ,
173+ inlineStyles : { } ,
174+ computedStyles : { } ,
175+ source : "self" ,
176+ ...overrides ,
177+ } ,
178+ ] ,
179+ } as DomEditSelection ;
180+ }
181+
182+ function segmentedRowButtons ( host : HTMLElement , label : string ) : HTMLButtonElement [ ] {
183+ const labelSpan = Array . from ( host . querySelectorAll ( "span" ) ) . find (
184+ ( el ) => el . textContent === label ,
185+ ) ;
186+ const row = labelSpan ?. parentElement ;
187+ return Array . from ( row ?. querySelectorAll < HTMLButtonElement > ( '[data-flat-segment="true"]' ) ?? [ ] ) ;
188+ }
189+
190+ describe ( "FlatTextFieldEditor controls" , ( ) => {
191+ it ( "commits text-transform: capitalize when the new 'Ag' case button is clicked" , ( ) => {
192+ const onSetTextFieldStyle = vi . fn ( ) ;
193+ const { host, root } = renderInto (
194+ < FlatTextSection
195+ element = { makeSingleFieldElement ( ) }
196+ styles = { { } }
197+ fontAssets = { [ ] }
198+ onSetText = { vi . fn ( ) }
199+ onSetTextFieldStyle = { onSetTextFieldStyle }
200+ onAddTextField = { vi . fn ( ) }
201+ onRemoveTextField = { vi . fn ( ) }
202+ /> ,
203+ ) ;
204+ const capitalizeButton = segmentedRowButtons ( host , "Case · Style" ) . find (
205+ ( button ) => button . textContent === "Ag" ,
206+ ) ;
207+ expect ( capitalizeButton ) . not . toBeUndefined ( ) ;
208+ act ( ( ) => capitalizeButton ?. dispatchEvent ( new MouseEvent ( "click" , { bubbles : true } ) ) ) ;
209+ expect ( onSetTextFieldStyle ) . toHaveBeenCalledWith ( "a" , "text-transform" , "capitalize" ) ;
210+ act ( ( ) => root . unmount ( ) ) ;
211+ } ) ;
212+
213+ it ( "lights up 'right' for text-align: end and commits the concrete 'right' value on click" , ( ) => {
214+ const onSetTextFieldStyle = vi . fn ( ) ;
215+ const { host, root } = renderInto (
216+ < FlatTextSection
217+ element = { makeSingleFieldElement ( { computedStyles : { "text-align" : "end" } } ) }
218+ styles = { { } }
219+ fontAssets = { [ ] }
220+ onSetText = { vi . fn ( ) }
221+ onSetTextFieldStyle = { onSetTextFieldStyle }
222+ onAddTextField = { vi . fn ( ) }
223+ onRemoveTextField = { vi . fn ( ) }
224+ /> ,
225+ ) ;
226+ const alignButtons = segmentedRowButtons ( host , "Align" ) ;
227+ const rightButton = alignButtons . find ( ( button ) => button . textContent === "R" ) ;
228+ expect ( rightButton ) . not . toBeUndefined ( ) ;
229+ expect ( rightButton ?. className ) . toContain ( "border-panel-accent" ) ;
230+ act ( ( ) => rightButton ?. dispatchEvent ( new MouseEvent ( "click" , { bubbles : true } ) ) ) ;
231+ expect ( onSetTextFieldStyle ) . toHaveBeenCalledWith ( "a" , "text-align" , "right" ) ;
232+ act ( ( ) => root . unmount ( ) ) ;
233+ } ) ;
234+
235+ it ( "live-commits the Size field on input, without requiring blur/Enter" , async ( ) => {
236+ const onSetTextFieldStyle = vi . fn ( ) ;
237+ const { host, root } = renderInto (
238+ < FlatTextSection
239+ element = { makeSingleFieldElement ( ) }
240+ styles = { { } }
241+ fontAssets = { [ ] }
242+ onSetText = { vi . fn ( ) }
243+ onSetTextFieldStyle = { onSetTextFieldStyle }
244+ onAddTextField = { vi . fn ( ) }
245+ onRemoveTextField = { vi . fn ( ) }
246+ /> ,
247+ ) ;
248+ const sizeLabel = Array . from ( host . querySelectorAll ( "span" ) ) . find (
249+ ( el ) => el . textContent === "Size" ,
250+ ) ;
251+ const input = sizeLabel ?. parentElement ?. querySelector < HTMLInputElement > ( "input" ) ;
252+ if ( ! input ) throw new Error ( "expected the Size row's input" ) ;
253+ act ( ( ) => {
254+ const nativeInputValueSetter = Object . getOwnPropertyDescriptor (
255+ window . HTMLInputElement . prototype ,
256+ "value" ,
257+ ) ?. set ;
258+ nativeInputValueSetter ?. call ( input , "24px" ) ;
259+ input . dispatchEvent ( new Event ( "input" , { bubbles : true } ) ) ;
260+ } ) ;
261+ // liveCommit debounces on a 120ms timer — no blur/Enter dispatched here.
262+ await act ( async ( ) => {
263+ await new Promise ( ( resolve ) => setTimeout ( resolve , 160 ) ) ;
264+ } ) ;
265+ expect ( onSetTextFieldStyle ) . toHaveBeenCalledWith ( "a" , "font-size" , "24px" ) ;
266+ act ( ( ) => root . unmount ( ) ) ;
267+ } ) ;
268+ } ) ;
269+
142270describe ( "FlatTextSection — multi-field" , ( ) => {
143271 it ( "shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)" , ( ) => {
144272 const host = document . createElement ( "div" ) ;
@@ -249,4 +377,65 @@ describe("FlatTextSection — multi-field", () => {
249377
250378 act ( ( ) => root . unmount ( ) ) ;
251379 } ) ;
380+
381+ it ( "auto-focuses the Content textarea when a new text field is added" , async ( ) => {
382+ let addResolved = false ;
383+
384+ function Harness ( ) {
385+ const [ fields , setFields ] = useState < DomEditTextField [ ] > ( makeMultiFieldElement ( ) . textFields ) ;
386+ const element : DomEditSelection = { ...makeMultiFieldElement ( ) , textFields : fields } ;
387+ return (
388+ < FlatTextSection
389+ element = { element }
390+ styles = { { } }
391+ fontAssets = { [ ] }
392+ onSetText = { vi . fn ( ) }
393+ onSetTextFieldStyle = { vi . fn ( ) }
394+ onAddTextField = { ( ) =>
395+ Promise . resolve ( ) . then ( ( ) => {
396+ addResolved = true ;
397+ setFields ( ( prev ) => [
398+ ...prev ,
399+ {
400+ key : "c" ,
401+ label : "Text" ,
402+ value : "" ,
403+ tagName : "div" ,
404+ attributes : [ ] ,
405+ inlineStyles : { } ,
406+ computedStyles : { } ,
407+ source : "self" ,
408+ } ,
409+ ] ) ;
410+ return "c" ;
411+ } )
412+ }
413+ onRemoveTextField = { vi . fn ( ) }
414+ />
415+ ) ;
416+ }
417+
418+ const host = document . createElement ( "div" ) ;
419+ document . body . append ( host ) ;
420+ const root = createRoot ( host ) ;
421+ act ( ( ) => {
422+ root . render ( < Harness /> ) ;
423+ } ) ;
424+
425+ const addButton = host . querySelector < HTMLButtonElement > ( '[data-flat-text-layer-add="true"]' ) ;
426+ // Wait for onAddTextField's promise to resolve (adds field "c" and makes it
427+ // active) before checking focus, mirroring the async add-field pattern above.
428+ await act ( async ( ) => {
429+ addButton ?. dispatchEvent ( new MouseEvent ( "click" , { bubbles : true } ) ) ;
430+ await Promise . resolve ( ) ;
431+ await Promise . resolve ( ) ;
432+ } ) ;
433+ expect ( addResolved ) . toBe ( true ) ;
434+
435+ const contentTextarea = host . querySelector ( "textarea" ) ;
436+ expect ( contentTextarea ) . not . toBeNull ( ) ;
437+ expect ( document . activeElement ) . toBe ( contentTextarea ) ;
438+
439+ act ( ( ) => root . unmount ( ) ) ;
440+ } ) ;
252441} ) ;
0 commit comments