3939 * a retry that cannot work; the input and the reason stay on screen
4040 * together.
4141 * 2. **The message had one carrier, and it was the transient one.** The toast
42- * is kept — it is the console's failure idiom and it is viewport-fixed, so
43- * it survives a tall `object-form` step scrolled past its own header — and
44- * an inline destructive `Alert` (`role="alert"`) now carries the same
45- * sentence inside the dialog, next to the values that produced it.
42+ * is kept — it is the console's failure idiom and it is viewport-fixed —
43+ * and an inline destructive `Alert` (`role="alert"`) now carries the same
44+ * sentence inside the dialog, next to the values that produced it. It sits
45+ * above the scrolling body (objectui#12080), so it stays in view however
46+ * far a tall screen is scrolled.
4647 * 3. **Success invalidated the wrong thing.** Both hosts answer `onComplete`
4748 * with `notifyDataChanged({ objectName: <this page's object> })`, which is
4849 * the record the user is LOOKING at — never the record the flow WROTE. The
143144 * `defaultValue` each key carries, which `check:i18n-keys` pins to its `en`
144145 * value. The server's own refusal sentence is passed through untranslated by
145146 * design — it is prose the backend composed, not a string with a key.
147+ *
148+ * ## A long screen still reaches its Submit (objectui#12080)
149+ *
150+ * The dialog is height-bounded on every screen, not only on an `object-form`
151+ * step: at most `90vh` tall, with the header (and any notice under it) on top,
152+ * the screen body in the ONE scrolling region, and the footer below it. The
153+ * upstream `DialogContent` has no height bound, so a flat screen taller than
154+ * the window used to overflow both edges of a fixed, scroll-locked overlay —
155+ * the heading above the top, Submit below the bottom, and nothing a mouse
156+ * wheel could move. Keeping the footer OUTSIDE the scrolling region is what
157+ * keeps Submit on screen however long the screen is.
158+ *
159+ * Width follows the screen's length, not a layout key: no spec key asks for
160+ * one, and the renderer can size by content. A flat screen declaring more
161+ * than {@link NARROW_SCREEN_MAX_FIELDS} fields gets the `object-form` step's
162+ * wider dialog and two columns from `sm` up; a shorter one keeps the narrow
163+ * single column it always had. The count is the DECLARED one, so a field a
164+ * `visibleWhen` reveals mid-edit never makes the dialog jump width.
165+ * `ActionParamDialog` takes the same bound for a long `params` list.
146166 */
147167import { Suspense , useEffect , useState } from 'react' ;
148168import {
@@ -155,6 +175,7 @@ import {
155175 DialogTitle ,
156176 DialogDescription ,
157177 Button ,
178+ cn ,
158179} from '@object-ui/components' ;
159180import { notifyDataChanged , useObjectTranslation } from '@object-ui/react' ;
160181import {
@@ -171,6 +192,7 @@ import {
171192 isObjectFormScreen ,
172193 initialScreenValues ,
173194 screenFieldBoundViolations ,
195+ screenFields ,
174196 visibleScreenFields ,
175197 type ScreenFieldSpec ,
176198 type ScreenSpec ,
@@ -179,6 +201,16 @@ import { interpretFlowResponse } from '../utils/flowResponse.js';
179201
180202export type { ScreenSpec , ScreenFieldSpec } from './ScreenView.js' ;
181203
204+ /**
205+ * The most declared fields a flat screen keeps the narrow single-column dialog
206+ * for; a longer one gets the wide two-column dialog (objectui#12080 — see the
207+ * header). Eight is the most single-line fields the narrow column was
208+ * measured to show whole, with no scrolling, inside the `90vh` bound on a
209+ * 1440×900 window — a reading taken once in Chromium for objectui#12080 and
210+ * recorded on its pull request; nothing here re-derives it.
211+ */
212+ const NARROW_SCREEN_MAX_FIELDS = 8 ;
213+
182214/** The `flows` group of a spec `TranslationData` — typed by the spec, so the address below is checked against it. */
183215type FlowsTranslation = NonNullable < TranslationData [ 'flows' ] > ;
184216
@@ -402,9 +434,9 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
402434 // #31). See utils/flowResponse.
403435 const outcome = interpretFlowResponse < ScreenSpec > ( res , json , 'Resume' ) ;
404436 if ( outcome . kind === 'failed' ) {
405- // Two carriers on purpose (`c40f3b8ca`): the toast is fixed to the viewport and
406- // reaches a user scrolled to the bottom of a tall object-form step; the
407- // inline Alert stays with the values that caused it.
437+ // Two carriers on purpose (`c40f3b8ca`): the toast is the console's
438+ // viewport-fixed failure idiom; the inline Alert stays in the dialog,
439+ // above the scrolling body, with the values that caused it.
408440 toast . error ( outcome . error ) ;
409441 setResumeError ( { message : outcome . error , retryable : outcome . retryable } ) ;
410442 return ;
@@ -531,10 +563,17 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
531563 // withdrawing the submit affordance is one behaviour with two causes, and
532564 // splitting it is how the `object-form` arm of it gets forgotten.
533565 const terminal = refused !== null || ( resumeError !== null && ! resumeError . retryable ) ;
566+ // A long flat screen widens to two columns (objectui#12080 — see the header).
567+ const isLongScreen = ! isObjectForm && screenFields ( screen ) . length > NARROW_SCREEN_MAX_FIELDS ;
534568
535569 return (
536570 < Dialog open onOpenChange = { ( o ) => { if ( ! o && ! submitting ) onClose ( ) ; } } >
537- < DialogContent className = { isObjectForm ? 'sm:max-w-3xl max-h-[90vh] overflow-y-auto' : 'sm:max-w-md' } >
571+ { /* Bounded on every screen (objectui#12080): a flex column at most 90vh
572+ tall, in which only the body below can give up height (`min-h-0`)
573+ and scroll, so the header and the footer always stay on screen. */ }
574+ < DialogContent
575+ className = { cn ( 'flex max-h-[90vh] flex-col' , isObjectForm || isLongScreen ? 'sm:max-w-3xl' : 'sm:max-w-md' ) }
576+ >
538577 < DialogHeader >
539578 { /* The flow being run (objectui#11092), above the step's own heading,
540579 which stays the dialog's title. */ }
@@ -565,34 +604,42 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat
565604 </ Alert >
566605 ) }
567606
568- { /* The screen body pulls in lazily-loaded chunks (an `object-form` step
569- mounts ObjectForm, whose field widgets are lazy). Without a boundary
570- HERE, that suspension unwinds to the host's nearest <Suspense> — a
571- route-level one on some surfaces — which swaps the whole page for a
572- fallback and destroys the host's state, taking this dialog (and the
573- run it is driving) with it. */ }
574- < Suspense fallback = { < div className = "py-6 text-sm text-muted-foreground" > { t ( 'common.loading' , { defaultValue : 'Loading…' } ) } </ div > } >
575- < ScreenView
576- screen = { shown }
577- values = { values }
578- onValueChange = { setVal }
579- dataSource = { dataSource }
580- objects = { objects }
581- objectForm = { {
582- onSuccess : onObjectFormSaved ,
583- onCancel : onClose ,
584- // Withdrawn once the run is gone: the record this step created was
585- // already persisted, so a second Save would duplicate it AND still
586- // have no suspension to resume.
587- showSubmit : ! terminal ,
588- showCancel : true ,
589- submitText : t ( 'flowRunner.saveAndContinue' , { defaultValue : 'Save & Continue' } ) ,
590- cancelText : terminal
591- ? t ( 'common.close' , { defaultValue : 'Close' } )
592- : t ( 'common.cancel' , { defaultValue : 'Cancel' } ) ,
593- } }
594- />
595- </ Suspense >
607+ { /* The ONE scrolling region (objectui#12080). `-mx-6 px-6` runs it to
608+ the dialog's edges, so the scrollbar sits on the border and a
609+ control's focus ring is not clipped at the sides. */ }
610+ < div className = "-mx-6 min-h-0 flex-1 overflow-y-auto px-6" data-testid = "flow-screen-body" >
611+ { /* The screen body pulls in lazily-loaded chunks (an `object-form` step
612+ mounts ObjectForm, whose field widgets are lazy). Without a boundary
613+ HERE, that suspension unwinds to the host's nearest <Suspense> — a
614+ route-level one on some surfaces — which swaps the whole page for a
615+ fallback and destroys the host's state, taking this dialog (and the
616+ run it is driving) with it. */ }
617+ < Suspense fallback = { < div className = "py-6 text-sm text-muted-foreground" > { t ( 'common.loading' , { defaultValue : 'Loading…' } ) } </ div > } >
618+ < ScreenView
619+ screen = { shown }
620+ values = { values }
621+ onValueChange = { setVal }
622+ dataSource = { dataSource }
623+ objects = { objects }
624+ // Two columns from `sm` up; `space-y-0` replaces the body's own
625+ // stack spacing, which would offset every grid cell but the last.
626+ className = { isLongScreen ? 'grid gap-4 space-y-0 sm:grid-cols-2' : undefined }
627+ objectForm = { {
628+ onSuccess : onObjectFormSaved ,
629+ onCancel : onClose ,
630+ // Withdrawn once the run is gone: the record this step created was
631+ // already persisted, so a second Save would duplicate it AND still
632+ // have no suspension to resume.
633+ showSubmit : ! terminal ,
634+ showCancel : true ,
635+ submitText : t ( 'flowRunner.saveAndContinue' , { defaultValue : 'Save & Continue' } ) ,
636+ cancelText : terminal
637+ ? t ( 'common.close' , { defaultValue : 'Close' } )
638+ : t ( 'common.cancel' , { defaultValue : 'Cancel' } ) ,
639+ } }
640+ />
641+ </ Suspense >
642+ </ div >
596643
597644 { ! isObjectForm && (
598645 < DialogFooter >
0 commit comments