@@ -199,6 +199,7 @@ let registered = false;
199199// Define a regex pattern to match {{number}}
200200const REGEX = / ( \{ \{ \d + \} \} ) / gm;
201201const DEFAULT_KEYPAD_VARIANT = 6 ;
202+ const KEYPAD_VIEWPORT_PADDING = 8 ;
202203
203204// !!! If you're using Chrome but have selected the "iPad" device in Chrome Developer Tools, the navigator.userAgent string may still report as
204205// Safari because Chrome on iOS actually uses the Safari rendering engine under the hood due to Apple's restrictions on third-party browser engines.
@@ -274,6 +275,60 @@ function prepareForStatic(model, state) {
274275 }
275276}
276277
278+ /**
279+ * Popper.js v2 modifier that implements precise horizontal placement:
280+ *
281+ * 1. Default: left-align the keypad with the left edge of the response area.
282+ *
283+ * 2. Overflow: if left-aligning would push the right edge of the keypad past
284+ * the viewport's right edge, right-align the keypad so that its right edge
285+ * sits exactly at the left edge of the response area.
286+ *
287+ * In both cases we compute offsets.x from first principles (rather than
288+ * adjusting whatever Popper already set) to avoid any upstream skew.
289+ *
290+ * Coordinate note:
291+ * state.rects.reference.x — reference's left edge in offset-parent coords.
292+ * getBoundingClientRect() — viewport-relative coords.
293+ * Both describe the same physical point, so the difference between them is
294+ * the constant offset needed to convert viewport ↔ offset-parent coords.
295+ */
296+ const smartHorizontalPlacementModifier = {
297+ name : 'smartHorizontalPlacement' ,
298+ enabled : true ,
299+ phase : 'main' ,
300+ requires : [ 'popperOffsets' ] ,
301+ fn : ( { state } ) => {
302+ const offsets = state . modifiersData . popperOffsets ;
303+ if ( ! offsets ) return ;
304+
305+ const viewportWidth = window . innerWidth || document . documentElement . clientWidth ;
306+ const popperWidth = state . rects . popper . width ;
307+
308+ const referenceViewportLeft = state . elements . reference . getBoundingClientRect ( ) . left ;
309+
310+ const offsetParentEl = state . elements . popper ?. offsetParent ;
311+ const offsetParentRect = offsetParentEl ?. getBoundingClientRect ?. ( ) || { left : 0 } ;
312+ const offsetParentScrollLeft = offsetParentEl ?. scrollLeft || 0 ;
313+ const referenceOffsetParentX = referenceViewportLeft - offsetParentRect . left + offsetParentScrollLeft ;
314+ const viewportLeftInOffsetParent = referenceOffsetParentX - referenceViewportLeft ;
315+ const minX = viewportLeftInOffsetParent + KEYPAD_VIEWPORT_PADDING ;
316+ const maxX = viewportLeftInOffsetParent + viewportWidth - popperWidth - KEYPAD_VIEWPORT_PADDING ;
317+
318+ if ( referenceViewportLeft + popperWidth <= viewportWidth ) {
319+ offsets . x = referenceOffsetParentX ;
320+ } else {
321+ offsets . x = referenceOffsetParentX - popperWidth ;
322+ }
323+
324+ if ( maxX < minX ) {
325+ offsets . x = minX ;
326+ } else {
327+ offsets . x = Math . min ( Math . max ( offsets . x , minX ) , maxX ) ;
328+ }
329+ } ,
330+ } ;
331+
277332export class Main extends React . Component {
278333 // removes {{ and }} and returns only key response. Eg: {{0}} => 0
279334 static getResponseKey = ( response ) => ( response || '' ) . replaceAll ( '{{' , '' ) . replaceAll ( '}}' , '' ) ;
@@ -293,7 +348,6 @@ export class Main extends React.Component {
293348 const { answers : sessionAnswers } = session || { } ;
294349
295350 if ( markup ) {
296- // build out local state model using responses declared in markup
297351 ( markup || '' ) . replace ( REGEX , ( response ) => {
298352 const responseKey = Main . getResponseKey ( response ) ;
299353 const sessionAnswerForResponse = sessionAnswers && sessionAnswers [ `r${ responseKey } ` ] ;
@@ -426,7 +480,25 @@ export class Main extends React.Component {
426480 }
427481
428482 onSubFieldFocus = ( id ) => {
429- this . setState ( { activeAnswerBlock : id } ) ;
483+ const editableFields = Array . from ( this . root ?. querySelectorAll ( '.mq-editable-field' ) || [ ] ) ;
484+ const savedScroll = editableFields . map ( ( el ) => ( {
485+ el,
486+ left : el . scrollLeft ,
487+ top : el . scrollTop ,
488+ } ) ) ;
489+
490+ const restoreEditableScroll = ( ) => {
491+ savedScroll . forEach ( ( { el, left, top } ) => {
492+ el . scrollLeft = left ;
493+ el . scrollTop = top ;
494+ } ) ;
495+ } ;
496+
497+ this . setState ( { activeAnswerBlock : id } , ( ) => {
498+ restoreEditableScroll ( ) ;
499+ requestAnimationFrame ( restoreEditableScroll ) ;
500+ setTimeout ( restoreEditableScroll , 0 ) ;
501+ } ) ;
430502 } ;
431503
432504 toNodeData = ( data ) => {
@@ -468,7 +540,43 @@ export class Main extends React.Component {
468540 this . input . write ( c . value ) ;
469541 }
470542
543+ // Keep all relevant scroll containers stable when refocusing after keypad
544+ // click. Browser "focus into view" can scroll horizontally back to start.
545+ const fieldElement = this . input ?. el ?. ( ) || this . root ;
546+ const scrollTargets = [ ] ;
547+ let node = fieldElement ;
548+
549+ while ( node && node !== document . body && node !== document . documentElement ) {
550+ const isScrollable = node . scrollWidth > node . clientWidth || node . scrollHeight > node . clientHeight ;
551+ if ( isScrollable ) {
552+ scrollTargets . push ( node ) ;
553+ }
554+ node = node . parentElement ;
555+ }
556+
557+ if ( document . scrollingElement ) {
558+ scrollTargets . push ( document . scrollingElement ) ;
559+ }
560+
561+ const savedScroll = scrollTargets . map ( ( el ) => ( {
562+ el,
563+ left : el . scrollLeft ,
564+ top : el . scrollTop ,
565+ } ) ) ;
566+ const windowScroll = { x : window . scrollX , y : window . scrollY } ;
567+
568+ const restoreScroll = ( ) => {
569+ savedScroll . forEach ( ( { el, left, top } ) => {
570+ el . scrollLeft = left ;
571+ el . scrollTop = top ;
572+ } ) ;
573+ window . scrollTo ( windowScroll . x , windowScroll . y ) ;
574+ } ;
575+
471576 this . input . focus ( ) ;
577+ restoreScroll ( ) ;
578+ requestAnimationFrame ( restoreScroll ) ;
579+ setTimeout ( restoreScroll , 0 ) ;
472580 } ;
473581
474582 callOnSessionChange = ( ) => {
@@ -677,30 +785,42 @@ export class Main extends React.Component {
677785 slotProps = { {
678786 popper : {
679787 container : tooltipContainerRef ?. current || undefined ,
680- placement : 'bottom-end' ,
788+ // 'bottom-start' left-aligns the keypad with the left edge of the
789+ // response area by default. The smartHorizontalPlacement modifier
790+ // below overrides this when the keypad would overflow the viewport.
791+ placement : 'bottom-start' ,
681792 sx : {
682793 backgroundColor : 'transparent' ,
683- width : '650px ' ,
794+ width : 'auto ' ,
684795 opacity : 1 ,
685796 '& .MuiTooltip-arrow' : {
686797 display : 'none' ,
687798 } ,
688799 } ,
689- modifiers : {
690- preventOverflow : {
800+ modifiers : [
801+ {
802+ name : 'preventOverflow' ,
691803 enabled : true ,
692- boundariesElement : 'body' ,
804+ options : {
805+ boundary : 'viewport' ,
806+ mainAxis : false ,
807+ altAxis : true ,
808+ } ,
693809 } ,
694- flip : {
810+ {
811+ name : 'flip' ,
695812 enabled : false ,
696813 } ,
697- } ,
814+
815+ smartHorizontalPlacementModifier ,
816+ ] ,
698817 } ,
699818 tooltip : {
700819 sx : {
701820 fontSize : 'initial' ,
702821 backgroundColor : 'transparent' ,
703- width : '600px' ,
822+ width : 'auto' ,
823+ maxWidth : 'none' ,
704824 marginTop : 0 ,
705825 paddingTop : 0 ,
706826 boxShadow : 'none' ,
0 commit comments