Skip to content

Commit 9769816

Browse files
committed
fix(math-templated): handle math keypad possition according to req, avoid kekpad move by adding content in input PIE-23
1 parent 8c88ef3 commit 9769816

1 file changed

Lines changed: 130 additions & 10 deletions

File tree

packages/math-templated/src/main.jsx

Lines changed: 130 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ let registered = false;
199199
// Define a regex pattern to match {{number}}
200200
const REGEX = /(\{\{\d+\}\})/gm;
201201
const 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+
277332
export 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

Comments
 (0)