mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
fix: restore mobile upper jaw touch gestures (#66752)
This commit is contained in:
@@ -0,0 +1,386 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { attachContentWidgetEvents } from './content-widget-events';
|
||||||
|
|
||||||
|
const createPointerEvent = (
|
||||||
|
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel',
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
options?: {
|
||||||
|
pointerId?: number;
|
||||||
|
pointerType?: 'touch' | 'mouse' | 'pen';
|
||||||
|
}
|
||||||
|
): PointerEvent => {
|
||||||
|
const pointerId = options?.pointerId ?? 1;
|
||||||
|
const pointerType = options?.pointerType ?? 'touch';
|
||||||
|
|
||||||
|
if (typeof PointerEvent === 'function') {
|
||||||
|
return new PointerEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerId,
|
||||||
|
pointerType,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new Event(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperties(event, {
|
||||||
|
pointerId: { value: pointerId },
|
||||||
|
pointerType: { value: pointerType },
|
||||||
|
clientX: { value: x },
|
||||||
|
clientY: { value: y }
|
||||||
|
});
|
||||||
|
|
||||||
|
return event as PointerEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTouchEvent = (
|
||||||
|
type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel',
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
identifier = 7
|
||||||
|
): TouchEvent => {
|
||||||
|
const touch = {
|
||||||
|
identifier,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y
|
||||||
|
};
|
||||||
|
const event = new Event(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
});
|
||||||
|
Object.defineProperty(event, 'changedTouches', {
|
||||||
|
value: {
|
||||||
|
item: () => touch
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return event as TouchEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDomTree = () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="monaco-editor">
|
||||||
|
<div class="monaco-scrollable-element editor-scrollable"></div>
|
||||||
|
<div class="editor-upper-jaw">
|
||||||
|
<div class="description-container">
|
||||||
|
<nav>
|
||||||
|
<a href="/learn/responsive-web-design-v9">Responsive Web Design</a>
|
||||||
|
</nav>
|
||||||
|
<p>paragraph</p>
|
||||||
|
<details class="code-details" open>
|
||||||
|
<summary class="code-details-summary">Example Code</summary>
|
||||||
|
<pre role="region">example code</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const monacoEditor = document.querySelector('.monaco-editor');
|
||||||
|
const scrollable = document.querySelector(
|
||||||
|
'.monaco-scrollable-element.editor-scrollable'
|
||||||
|
);
|
||||||
|
const upperJaw = document.querySelector('.editor-upper-jaw');
|
||||||
|
const paragraph = document.querySelector('.description-container p');
|
||||||
|
const breadcrumbLink = document.querySelector('.description-container a');
|
||||||
|
const summary = document.querySelector(
|
||||||
|
'.editor-upper-jaw details.code-details summary.code-details-summary'
|
||||||
|
);
|
||||||
|
const pre = document.querySelector(
|
||||||
|
'.editor-upper-jaw details.code-details pre'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(monacoEditor instanceof HTMLElement) ||
|
||||||
|
!(scrollable instanceof HTMLElement) ||
|
||||||
|
!(upperJaw instanceof HTMLElement) ||
|
||||||
|
!(paragraph instanceof HTMLElement) ||
|
||||||
|
!(breadcrumbLink instanceof HTMLAnchorElement) ||
|
||||||
|
!(summary instanceof HTMLElement) ||
|
||||||
|
!(pre instanceof HTMLElement)
|
||||||
|
) {
|
||||||
|
throw new Error('Failed to construct test DOM');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(pre, 'scrollWidth', { value: 500, configurable: true });
|
||||||
|
Object.defineProperty(pre, 'clientWidth', { value: 200, configurable: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
monacoEditor,
|
||||||
|
scrollable,
|
||||||
|
upperJaw,
|
||||||
|
paragraph,
|
||||||
|
breadcrumbLink,
|
||||||
|
summary,
|
||||||
|
pre
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('content widget event handling', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops bubbling for contextmenu while preserving default behavior', () => {
|
||||||
|
const { monacoEditor, upperJaw, paragraph } = createDomTree();
|
||||||
|
const onParentContextMenu = vi.fn();
|
||||||
|
monacoEditor.addEventListener('contextmenu', onParentContextMenu);
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
|
||||||
|
const contextMenu = new MouseEvent('contextmenu', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
});
|
||||||
|
paragraph.dispatchEvent(contextMenu);
|
||||||
|
|
||||||
|
expect(onParentContextMenu).not.toHaveBeenCalled();
|
||||||
|
expect(contextMenu.defaultPrevented).toBe(false);
|
||||||
|
detachListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches wheel scroll for vertical touch drags and stops propagation', () => {
|
||||||
|
const { monacoEditor, scrollable, upperJaw, paragraph } = createDomTree();
|
||||||
|
const wheelEvents: WheelEvent[] = [];
|
||||||
|
scrollable.addEventListener('wheel', e => {
|
||||||
|
wheelEvents.push(e);
|
||||||
|
});
|
||||||
|
const onParentPointerMove = vi.fn();
|
||||||
|
monacoEditor.addEventListener('pointermove', onParentPointerMove);
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
|
||||||
|
paragraph.dispatchEvent(createPointerEvent('pointerdown', 100, 100));
|
||||||
|
const pointerMove = createPointerEvent('pointermove', 100, 140);
|
||||||
|
paragraph.dispatchEvent(pointerMove);
|
||||||
|
|
||||||
|
expect(wheelEvents).toHaveLength(1);
|
||||||
|
expect(wheelEvents[0]?.deltaY).toBe(100);
|
||||||
|
expect(pointerMove.defaultPrevented).toBe(true);
|
||||||
|
expect(onParentPointerMove).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
detachListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not force vertical wheel scroll for horizontal code-region drags', () => {
|
||||||
|
const { monacoEditor, scrollable, upperJaw, pre } = createDomTree();
|
||||||
|
const wheelEvents: WheelEvent[] = [];
|
||||||
|
scrollable.addEventListener('wheel', e => {
|
||||||
|
wheelEvents.push(e);
|
||||||
|
});
|
||||||
|
const onParentPointerMove = vi.fn();
|
||||||
|
monacoEditor.addEventListener('pointermove', onParentPointerMove);
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
|
||||||
|
pre.dispatchEvent(createPointerEvent('pointerdown', 100, 100));
|
||||||
|
const pointerMove = createPointerEvent('pointermove', 160, 112);
|
||||||
|
pre.dispatchEvent(pointerMove);
|
||||||
|
|
||||||
|
expect(wheelEvents).toHaveLength(0);
|
||||||
|
expect(pointerMove.defaultPrevented).toBe(false);
|
||||||
|
expect(onParentPointerMove).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
detachListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats non-scrollable pre drags as vertical gestures', () => {
|
||||||
|
const { scrollable, upperJaw, pre } = createDomTree();
|
||||||
|
pre.removeAttribute('role');
|
||||||
|
Object.defineProperty(pre, 'scrollWidth', {
|
||||||
|
value: 200,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
Object.defineProperty(pre, 'clientWidth', {
|
||||||
|
value: 200,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
const wheelEvents: WheelEvent[] = [];
|
||||||
|
scrollable.addEventListener('wheel', e => {
|
||||||
|
wheelEvents.push(e);
|
||||||
|
});
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
|
||||||
|
pre.dispatchEvent(createPointerEvent('pointerdown', 100, 100));
|
||||||
|
const pointerMove = createPointerEvent('pointermove', 160, 130);
|
||||||
|
pre.dispatchEvent(pointerMove);
|
||||||
|
|
||||||
|
expect(wheelEvents).toHaveLength(1);
|
||||||
|
expect(pointerMove.defaultPrevented).toBe(true);
|
||||||
|
|
||||||
|
detachListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps breadcrumb touches from bubbling to parent handlers', () => {
|
||||||
|
const { monacoEditor, scrollable, upperJaw, breadcrumbLink } =
|
||||||
|
createDomTree();
|
||||||
|
const wheelEvents: WheelEvent[] = [];
|
||||||
|
scrollable.addEventListener('wheel', e => {
|
||||||
|
wheelEvents.push(e);
|
||||||
|
});
|
||||||
|
const onParentPointerMove = vi.fn();
|
||||||
|
monacoEditor.addEventListener('pointermove', onParentPointerMove);
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
|
||||||
|
breadcrumbLink.dispatchEvent(createPointerEvent('pointerdown', 100, 100));
|
||||||
|
const pointerMove = createPointerEvent('pointermove', 100, 140);
|
||||||
|
breadcrumbLink.dispatchEvent(pointerMove);
|
||||||
|
|
||||||
|
expect(wheelEvents).toHaveLength(0);
|
||||||
|
expect(pointerMove.defaultPrevented).toBe(false);
|
||||||
|
expect(onParentPointerMove).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
detachListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps example-code summary touches from bubbling to parent handlers', () => {
|
||||||
|
const { monacoEditor, scrollable, upperJaw, summary } = createDomTree();
|
||||||
|
const wheelEvents: WheelEvent[] = [];
|
||||||
|
scrollable.addEventListener('wheel', e => {
|
||||||
|
wheelEvents.push(e);
|
||||||
|
});
|
||||||
|
const onParentPointerMove = vi.fn();
|
||||||
|
monacoEditor.addEventListener('pointermove', onParentPointerMove);
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
|
||||||
|
summary.dispatchEvent(createPointerEvent('pointerdown', 100, 100));
|
||||||
|
const pointerMove = createPointerEvent('pointermove', 100, 140);
|
||||||
|
summary.dispatchEvent(pointerMove);
|
||||||
|
|
||||||
|
expect(wheelEvents).toHaveLength(0);
|
||||||
|
expect(pointerMove.defaultPrevented).toBe(false);
|
||||||
|
expect(onParentPointerMove).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
detachListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch wheel for tiny movements below threshold', () => {
|
||||||
|
const { monacoEditor, scrollable, upperJaw, paragraph } = createDomTree();
|
||||||
|
const wheelEvents: WheelEvent[] = [];
|
||||||
|
scrollable.addEventListener('wheel', e => {
|
||||||
|
wheelEvents.push(e);
|
||||||
|
});
|
||||||
|
const onParentPointerMove = vi.fn();
|
||||||
|
monacoEditor.addEventListener('pointermove', onParentPointerMove);
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
|
||||||
|
paragraph.dispatchEvent(createPointerEvent('pointerdown', 100, 100));
|
||||||
|
const pointerMove = createPointerEvent('pointermove', 103, 104);
|
||||||
|
paragraph.dispatchEvent(pointerMove);
|
||||||
|
|
||||||
|
expect(wheelEvents).toHaveLength(0);
|
||||||
|
expect(pointerMove.defaultPrevented).toBe(false);
|
||||||
|
expect(onParentPointerMove).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
detachListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets touch state on pointerup', () => {
|
||||||
|
const { monacoEditor, scrollable, upperJaw, paragraph } = createDomTree();
|
||||||
|
const wheelEvents: WheelEvent[] = [];
|
||||||
|
scrollable.addEventListener('wheel', e => {
|
||||||
|
wheelEvents.push(e);
|
||||||
|
});
|
||||||
|
const onParentPointerMove = vi.fn();
|
||||||
|
monacoEditor.addEventListener('pointermove', onParentPointerMove);
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
|
||||||
|
paragraph.dispatchEvent(createPointerEvent('pointerdown', 100, 100));
|
||||||
|
paragraph.dispatchEvent(createPointerEvent('pointermove', 100, 140));
|
||||||
|
paragraph.dispatchEvent(createPointerEvent('pointerup', 100, 140));
|
||||||
|
paragraph.dispatchEvent(createPointerEvent('pointermove', 100, 180));
|
||||||
|
|
||||||
|
expect(wheelEvents).toHaveLength(1);
|
||||||
|
expect(onParentPointerMove).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
detachListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detaches all listeners cleanly', () => {
|
||||||
|
const { monacoEditor, scrollable, upperJaw, paragraph } = createDomTree();
|
||||||
|
const wheelEvents: WheelEvent[] = [];
|
||||||
|
scrollable.addEventListener('wheel', e => {
|
||||||
|
wheelEvents.push(e);
|
||||||
|
});
|
||||||
|
const onParentPointerMove = vi.fn();
|
||||||
|
const onParentContextMenu = vi.fn();
|
||||||
|
monacoEditor.addEventListener('pointermove', onParentPointerMove);
|
||||||
|
monacoEditor.addEventListener('contextmenu', onParentContextMenu);
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
detachListeners();
|
||||||
|
|
||||||
|
paragraph.dispatchEvent(createPointerEvent('pointerdown', 100, 100));
|
||||||
|
paragraph.dispatchEvent(createPointerEvent('pointermove', 100, 140));
|
||||||
|
paragraph.dispatchEvent(
|
||||||
|
new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wheelEvents).toHaveLength(0);
|
||||||
|
expect(onParentPointerMove).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onParentContextMenu).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-touch pointer events', () => {
|
||||||
|
const { scrollable, upperJaw, paragraph } = createDomTree();
|
||||||
|
const wheelEvents: WheelEvent[] = [];
|
||||||
|
scrollable.addEventListener('wheel', e => {
|
||||||
|
wheelEvents.push(e);
|
||||||
|
});
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
|
||||||
|
paragraph.dispatchEvent(
|
||||||
|
createPointerEvent('pointerdown', 100, 100, { pointerType: 'mouse' })
|
||||||
|
);
|
||||||
|
paragraph.dispatchEvent(
|
||||||
|
createPointerEvent('pointermove', 100, 160, { pointerType: 'mouse' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wheelEvents).toHaveLength(0);
|
||||||
|
|
||||||
|
detachListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports touch events when pointer events are not emitted', () => {
|
||||||
|
const { monacoEditor, scrollable, upperJaw, paragraph } = createDomTree();
|
||||||
|
const wheelEvents: WheelEvent[] = [];
|
||||||
|
scrollable.addEventListener('wheel', e => {
|
||||||
|
wheelEvents.push(e);
|
||||||
|
});
|
||||||
|
const onParentTouchMove = vi.fn();
|
||||||
|
monacoEditor.addEventListener('touchmove', onParentTouchMove);
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
|
||||||
|
paragraph.dispatchEvent(createTouchEvent('touchstart', 100, 100, 11));
|
||||||
|
const touchMove = createTouchEvent('touchmove', 100, 140, 11);
|
||||||
|
paragraph.dispatchEvent(touchMove);
|
||||||
|
paragraph.dispatchEvent(createTouchEvent('touchend', 100, 140, 11));
|
||||||
|
|
||||||
|
expect(wheelEvents).toHaveLength(1);
|
||||||
|
expect(wheelEvents[0]?.deltaY).toBe(100);
|
||||||
|
expect(touchMove.defaultPrevented).toBe(true);
|
||||||
|
expect(onParentTouchMove).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
detachListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pointer gesture state when compatibility touch events also fire', () => {
|
||||||
|
const { scrollable, upperJaw, paragraph } = createDomTree();
|
||||||
|
const wheelEvents: WheelEvent[] = [];
|
||||||
|
scrollable.addEventListener('wheel', e => {
|
||||||
|
wheelEvents.push(e);
|
||||||
|
});
|
||||||
|
const detachListeners = attachContentWidgetEvents(upperJaw);
|
||||||
|
|
||||||
|
paragraph.dispatchEvent(createPointerEvent('pointerdown', 100, 100));
|
||||||
|
paragraph.dispatchEvent(createTouchEvent('touchstart', 95, 95, 22));
|
||||||
|
paragraph.dispatchEvent(createPointerEvent('pointermove', 100, 140));
|
||||||
|
|
||||||
|
expect(wheelEvents).toHaveLength(1);
|
||||||
|
expect(wheelEvents[0]?.deltaY).toBe(100);
|
||||||
|
|
||||||
|
detachListeners();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
type TouchGestureMode = 'pending' | 'vertical' | 'horizontal';
|
||||||
|
type TouchSource = 'pointer' | 'touch';
|
||||||
|
|
||||||
|
interface TouchGestureState {
|
||||||
|
source: TouchSource;
|
||||||
|
pointerId: number;
|
||||||
|
mode: TouchGestureMode;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
lastY: number;
|
||||||
|
horizontalScroller: HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InteractiveTouchState {
|
||||||
|
source: TouchSource;
|
||||||
|
pointerId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOVE_THRESHOLD = 8;
|
||||||
|
const TOUCH_VERTICAL_SCROLL_MULTIPLIER = 2.5;
|
||||||
|
const INTERACTIVE_JAW_SELECTOR =
|
||||||
|
'a, summary.code-details-summary, pre[role="region"]';
|
||||||
|
|
||||||
|
const isInteractiveTarget = (target: EventTarget | null): boolean =>
|
||||||
|
target instanceof Element && !!target.closest(INTERACTIVE_JAW_SELECTOR);
|
||||||
|
|
||||||
|
const findHorizontalScroller = (
|
||||||
|
target: EventTarget | null
|
||||||
|
): HTMLElement | null => {
|
||||||
|
if (!(target instanceof Element)) return null;
|
||||||
|
const pre = target.closest('pre');
|
||||||
|
if (!(pre instanceof HTMLElement)) return null;
|
||||||
|
return pre.scrollWidth > pre.clientWidth ? pre : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchVerticalScrollWheel = (
|
||||||
|
upperJawNode: HTMLElement,
|
||||||
|
deltaY: number
|
||||||
|
): void => {
|
||||||
|
if (deltaY === 0) return;
|
||||||
|
const editorScrollable = upperJawNode
|
||||||
|
.closest('.monaco-editor')
|
||||||
|
?.querySelector('.monaco-scrollable-element.editor-scrollable');
|
||||||
|
|
||||||
|
if (!(editorScrollable instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
editorScrollable.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGestureMode = (
|
||||||
|
touchState: TouchGestureState,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number
|
||||||
|
): void => {
|
||||||
|
const deltaX = clientX - touchState.startX;
|
||||||
|
const deltaY = clientY - touchState.startY;
|
||||||
|
|
||||||
|
if (
|
||||||
|
touchState.mode === 'pending' &&
|
||||||
|
Math.max(Math.abs(deltaX), Math.abs(deltaY)) >= MOVE_THRESHOLD
|
||||||
|
) {
|
||||||
|
touchState.mode =
|
||||||
|
touchState.horizontalScroller && Math.abs(deltaX) > Math.abs(deltaY)
|
||||||
|
? 'horizontal'
|
||||||
|
: 'vertical';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerticalScroll = (
|
||||||
|
e: Event,
|
||||||
|
upperJawNode: HTMLElement,
|
||||||
|
touchState: TouchGestureState,
|
||||||
|
clientY: number
|
||||||
|
): void => {
|
||||||
|
if (touchState.mode !== 'vertical') return;
|
||||||
|
|
||||||
|
if (e.cancelable) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchVerticalScrollWheel(
|
||||||
|
upperJawNode,
|
||||||
|
(clientY - touchState.lastY) * TOUCH_VERTICAL_SCROLL_MULTIPLIER
|
||||||
|
);
|
||||||
|
touchState.lastY = clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const attachContentWidgetEvents = (
|
||||||
|
upperJawNode: HTMLElement
|
||||||
|
): (() => void) => {
|
||||||
|
let touchState: TouchGestureState | null = null;
|
||||||
|
let interactiveTouchState: InteractiveTouchState | null = null;
|
||||||
|
|
||||||
|
const onContextMenu = (e: MouseEvent): void => {
|
||||||
|
if (isInteractiveTarget(e.target)) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent): void => {
|
||||||
|
if (e.pointerType !== 'touch') return;
|
||||||
|
if (isInteractiveTarget(e.target)) {
|
||||||
|
interactiveTouchState = {
|
||||||
|
source: 'pointer',
|
||||||
|
pointerId: e.pointerId
|
||||||
|
};
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
touchState = {
|
||||||
|
source: 'pointer',
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
mode: 'pending',
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
lastY: e.clientY,
|
||||||
|
horizontalScroller: findHorizontalScroller(e.target)
|
||||||
|
};
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent): void => {
|
||||||
|
if (
|
||||||
|
interactiveTouchState &&
|
||||||
|
interactiveTouchState.source === 'pointer' &&
|
||||||
|
e.pointerId === interactiveTouchState.pointerId
|
||||||
|
) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
e.pointerType !== 'touch' ||
|
||||||
|
!touchState ||
|
||||||
|
touchState.source !== 'pointer' ||
|
||||||
|
e.pointerId !== touchState.pointerId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGestureMode(touchState, e.clientX, e.clientY);
|
||||||
|
handleVerticalScroll(e, upperJawNode, touchState, e.clientY);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = (e: PointerEvent): void => {
|
||||||
|
if (
|
||||||
|
interactiveTouchState &&
|
||||||
|
interactiveTouchState.source === 'pointer' &&
|
||||||
|
e.pointerId === interactiveTouchState.pointerId
|
||||||
|
) {
|
||||||
|
interactiveTouchState = null;
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!touchState ||
|
||||||
|
touchState.source !== 'pointer' ||
|
||||||
|
e.pointerId !== touchState.pointerId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
touchState = null;
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchStart = (e: TouchEvent): void => {
|
||||||
|
if (touchState?.source === 'pointer') return;
|
||||||
|
|
||||||
|
const touch = e.changedTouches.item(0);
|
||||||
|
if (!touch) return;
|
||||||
|
|
||||||
|
if (isInteractiveTarget(e.target)) {
|
||||||
|
interactiveTouchState = {
|
||||||
|
source: 'touch',
|
||||||
|
pointerId: touch.identifier
|
||||||
|
};
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
touchState = {
|
||||||
|
source: 'touch',
|
||||||
|
pointerId: touch.identifier,
|
||||||
|
mode: 'pending',
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
lastY: touch.clientY,
|
||||||
|
horizontalScroller: findHorizontalScroller(e.target)
|
||||||
|
};
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchMove = (e: TouchEvent): void => {
|
||||||
|
if (interactiveTouchState?.source === 'touch') {
|
||||||
|
const interactiveTouch = e.changedTouches.item(0);
|
||||||
|
if (
|
||||||
|
interactiveTouch &&
|
||||||
|
interactiveTouch.identifier === interactiveTouchState.pointerId
|
||||||
|
) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!touchState || touchState.source !== 'touch') return;
|
||||||
|
|
||||||
|
const touch = e.changedTouches.item(0);
|
||||||
|
if (!touch || touch.identifier !== touchState.pointerId) return;
|
||||||
|
|
||||||
|
updateGestureMode(touchState, touch.clientX, touch.clientY);
|
||||||
|
handleVerticalScroll(e, upperJawNode, touchState, touch.clientY);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = (e: TouchEvent): void => {
|
||||||
|
if (interactiveTouchState?.source === 'touch') {
|
||||||
|
const interactiveTouch = e.changedTouches.item(0);
|
||||||
|
if (
|
||||||
|
interactiveTouch &&
|
||||||
|
interactiveTouch.identifier === interactiveTouchState.pointerId
|
||||||
|
) {
|
||||||
|
interactiveTouchState = null;
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!touchState || touchState.source !== 'touch') return;
|
||||||
|
|
||||||
|
const touch = e.changedTouches.item(0);
|
||||||
|
if (!touch || touch.identifier !== touchState.pointerId) return;
|
||||||
|
|
||||||
|
touchState = null;
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
upperJawNode.addEventListener('contextmenu', onContextMenu);
|
||||||
|
upperJawNode.addEventListener('pointerdown', onPointerDown, {
|
||||||
|
passive: true
|
||||||
|
});
|
||||||
|
upperJawNode.addEventListener('pointermove', onPointerMove, {
|
||||||
|
passive: false
|
||||||
|
});
|
||||||
|
upperJawNode.addEventListener('pointerup', onPointerUp, { passive: true });
|
||||||
|
upperJawNode.addEventListener('pointercancel', onPointerUp, {
|
||||||
|
passive: true
|
||||||
|
});
|
||||||
|
upperJawNode.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||||
|
upperJawNode.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
|
upperJawNode.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||||
|
upperJawNode.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
upperJawNode.removeEventListener('contextmenu', onContextMenu);
|
||||||
|
upperJawNode.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
upperJawNode.removeEventListener('pointermove', onPointerMove);
|
||||||
|
upperJawNode.removeEventListener('pointerup', onPointerUp);
|
||||||
|
upperJawNode.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
upperJawNode.removeEventListener('touchstart', onTouchStart);
|
||||||
|
upperJawNode.removeEventListener('touchmove', onTouchMove);
|
||||||
|
upperJawNode.removeEventListener('touchend', onTouchEnd);
|
||||||
|
upperJawNode.removeEventListener('touchcancel', onTouchEnd);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -53,6 +53,8 @@
|
|||||||
|
|
||||||
.editor-upper-jaw {
|
.editor-upper-jaw {
|
||||||
max-width: unset !important;
|
max-width: unset !important;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-upper-jaw code,
|
.editor-upper-jaw code,
|
||||||
@@ -60,6 +62,11 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-upper-jaw pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-file-name {
|
.editor-file-name {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import { getScrollbarWidth } from '../../../utils/scrollbar-width';
|
|||||||
import { isProjectBased } from '../../../utils/curriculum-layout';
|
import { isProjectBased } from '../../../utils/curriculum-layout';
|
||||||
import envConfig from '../../../../config/env.json';
|
import envConfig from '../../../../config/env.json';
|
||||||
import LowerJaw from './lower-jaw';
|
import LowerJaw from './lower-jaw';
|
||||||
|
import { attachContentWidgetEvents } from './content-widget-events';
|
||||||
// Direct from npm, license in react-types-licence
|
// Direct from npm, license in react-types-licence
|
||||||
import reactTypes from './react-types.json';
|
import reactTypes from './react-types.json';
|
||||||
|
|
||||||
@@ -315,6 +316,7 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
|
|
||||||
const submitChallenge = useSubmit();
|
const submitChallenge = useSubmit();
|
||||||
|
|
||||||
|
const detachUpperJawEventsRef = useRef<(() => void) | null>(null);
|
||||||
const player = useRef<{
|
const player = useRef<{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
sampler: any;
|
sampler: any;
|
||||||
@@ -334,6 +336,13 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
const attemptsRef = useRef<number>(0);
|
const attemptsRef = useRef<number>(0);
|
||||||
attemptsRef.current = props.attempts;
|
attemptsRef.current = props.attempts;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
detachUpperJawEventsRef.current?.();
|
||||||
|
detachUpperJawEventsRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const challengeFile = challengeFiles?.find(
|
const challengeFile = challengeFiles?.find(
|
||||||
challengeFile => challengeFile.fileKey === fileKey
|
challengeFile => challengeFile.fileKey === fileKey
|
||||||
);
|
);
|
||||||
@@ -367,7 +376,8 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
verticalScrollbarSize: getScrollbarWidth(),
|
verticalScrollbarSize: getScrollbarWidth(),
|
||||||
// this helps the scroll bar fit properly between the arrows,
|
// this helps the scroll bar fit properly between the arrows,
|
||||||
// but doesn't do anything for the arrows themselves
|
// but doesn't do anything for the arrows themselves
|
||||||
arrowSize: getScrollbarWidth()
|
arrowSize: getScrollbarWidth(),
|
||||||
|
alwaysConsumeMouseWheel: false
|
||||||
},
|
},
|
||||||
parameterHints: {
|
parameterHints: {
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -880,6 +890,8 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
descContainer.classList.add('mathjax-support');
|
descContainer.classList.add('mathjax-support');
|
||||||
}
|
}
|
||||||
domNode.classList.add('editor-upper-jaw');
|
domNode.classList.add('editor-upper-jaw');
|
||||||
|
detachUpperJawEventsRef.current?.();
|
||||||
|
detachUpperJawEventsRef.current = attachContentWidgetEvents(domNode);
|
||||||
domNode.appendChild(descContainer);
|
domNode.appendChild(descContainer);
|
||||||
if (isMobileLayout) descContainer.appendChild(createBreadcrumb());
|
if (isMobileLayout) descContainer.appendChild(createBreadcrumb());
|
||||||
descContainer.appendChild(jawHeading);
|
descContainer.appendChild(jawHeading);
|
||||||
@@ -899,6 +911,7 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
obs.observe(domNode);
|
obs.observe(domNode);
|
||||||
|
|
||||||
domNode.style.userSelect = 'text';
|
domNode.style.userSelect = 'text';
|
||||||
|
domNode.style.webkitUserSelect = 'text';
|
||||||
|
|
||||||
domNode.style.left = `${editor.getLayoutInfo().contentLeft}px`;
|
domNode.style.left = `${editor.getLayoutInfo().contentLeft}px`;
|
||||||
domNode.style.width = `${getEditorContentWidth(editor)}px`;
|
domNode.style.width = `${getEditorContentWidth(editor)}px`;
|
||||||
@@ -1151,7 +1164,8 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
domNode: HTMLDivElement,
|
domNode: HTMLDivElement,
|
||||||
// If getTop function is not provided then no positioning will be done here.
|
// If getTop function is not provided then no positioning will be done here.
|
||||||
// This allows scroll gutter to do its positioning elsewhere.
|
// This allows scroll gutter to do its positioning elsewhere.
|
||||||
getTop?: () => string
|
getTop?: () => string,
|
||||||
|
suppressMouseDown = false
|
||||||
) => {
|
) => {
|
||||||
const getId = () => id;
|
const getId = () => id;
|
||||||
const getDomNode = () => domNode;
|
const getDomNode = () => domNode;
|
||||||
@@ -1175,7 +1189,8 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
getId,
|
getId,
|
||||||
getDomNode,
|
getDomNode,
|
||||||
getPosition,
|
getPosition,
|
||||||
afterRender
|
afterRender,
|
||||||
|
suppressMouseDown
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1192,7 +1207,8 @@ const Editor = (props: EditorProps): JSX.Element => {
|
|||||||
editor,
|
editor,
|
||||||
'description.widget',
|
'description.widget',
|
||||||
descriptionNode,
|
descriptionNode,
|
||||||
getDescriptionZoneTop
|
getDescriptionZoneTop,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
// this order (add widget, change zone) is necessary, since the zone
|
// this order (add widget, change zone) is necessary, since the zone
|
||||||
// relies on the domnode being in the DOM to calculate its height - that
|
// relies on the domnode being in the DOM to calculate its height - that
|
||||||
|
|||||||
@@ -156,15 +156,6 @@ const BASE_LAYOUT = {
|
|||||||
testsPane: { flex: 0.3 }
|
testsPane: { flex: 0.3 }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Used to prevent monaco from stealing mouse/touch events on the upper jaw
|
|
||||||
// content widget so they can trigger their default actions. (Issue #46166)
|
|
||||||
const handleContentWidgetEvents = (e: MouseEvent | TouchEvent): void => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target?.closest('.editor-upper-jaw')) {
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const StepPreview = ({
|
const StepPreview = ({
|
||||||
dimensions,
|
dimensions,
|
||||||
disableIframe,
|
disableIframe,
|
||||||
@@ -332,13 +323,6 @@ function ShowClassic({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeComponent(title);
|
initializeComponent(title);
|
||||||
// Bug fix for the monaco content widget and touch devices/right mouse
|
|
||||||
// click. (Issue #46166)
|
|
||||||
document.addEventListener('mousedown', handleContentWidgetEvents, true);
|
|
||||||
document.addEventListener('contextmenu', handleContentWidgetEvents, true);
|
|
||||||
document.addEventListener('touchstart', handleContentWidgetEvents, true);
|
|
||||||
document.addEventListener('touchmove', handleContentWidgetEvents, true);
|
|
||||||
document.addEventListener('touchend', handleContentWidgetEvents, true);
|
|
||||||
|
|
||||||
window.addEventListener('resize', setHtmlHeight);
|
window.addEventListener('resize', setHtmlHeight);
|
||||||
setHtmlHeight();
|
setHtmlHeight();
|
||||||
@@ -346,27 +330,6 @@ function ShowClassic({
|
|||||||
return () => {
|
return () => {
|
||||||
createFiles([]);
|
createFiles([]);
|
||||||
cancelTests();
|
cancelTests();
|
||||||
document.removeEventListener(
|
|
||||||
'mousedown',
|
|
||||||
handleContentWidgetEvents,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
document.removeEventListener(
|
|
||||||
'contextmenu',
|
|
||||||
handleContentWidgetEvents,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
document.removeEventListener(
|
|
||||||
'touchstart',
|
|
||||||
handleContentWidgetEvents,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
document.removeEventListener(
|
|
||||||
'touchmove',
|
|
||||||
handleContentWidgetEvents,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
document.removeEventListener('touchend', handleContentWidgetEvents, true);
|
|
||||||
window.removeEventListener('resize', setHtmlHeight);
|
window.removeEventListener('resize', setHtmlHeight);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { test, expect, type Locator, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const upperJawSelector = '.editor-upper-jaw';
|
||||||
|
const challengeWithExampleUrl =
|
||||||
|
'/learn/responsive-web-design-v9/workshop-cat-photo-app/step-4';
|
||||||
|
const challengeWithHorizontalExampleCodeUrl =
|
||||||
|
'/learn/responsive-web-design-v9/workshop-cat-photo-app/step-11';
|
||||||
|
const challengeWithLongContentUrl =
|
||||||
|
'/learn/responsive-web-design-v9/workshop-cat-photo-app/step-42';
|
||||||
|
|
||||||
|
const openChallenge = async (page: Page, challengeUrl: string) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('hideMobileAppModal', 'true');
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'mobileAppModalDismissedAt',
|
||||||
|
JSON.stringify(Date.now())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(challengeUrl);
|
||||||
|
await page.locator(upperJawSelector).waitFor({ state: 'visible' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTouchPoint = async (
|
||||||
|
page: Page,
|
||||||
|
locator: Locator,
|
||||||
|
options: { xRatio?: number; yRatio?: number } = {}
|
||||||
|
): Promise<{ x: number; y: number }> => {
|
||||||
|
const box = await locator.boundingBox();
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
const { xRatio = 0.5, yRatio = 0.4 } = options;
|
||||||
|
|
||||||
|
if (!box || !viewport) {
|
||||||
|
throw new Error('Touch geometry unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = Math.round(
|
||||||
|
Math.min(Math.max(box.x + box.width * xRatio, 10), viewport.width - 10)
|
||||||
|
);
|
||||||
|
const y = Math.round(
|
||||||
|
Math.min(Math.max(box.y + box.height * yRatio, 10), viewport.height - 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragWithHeldTouch = async (
|
||||||
|
page: Page,
|
||||||
|
locator: Locator,
|
||||||
|
options: {
|
||||||
|
startXRatio?: number;
|
||||||
|
startYRatio?: number;
|
||||||
|
moveByX?: number;
|
||||||
|
moveByY?: number;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
startXRatio = 0.5,
|
||||||
|
startYRatio = 0.4,
|
||||||
|
moveByX = 0,
|
||||||
|
moveByY = -80
|
||||||
|
} = options;
|
||||||
|
const { x, y } = await getTouchPoint(page, locator, {
|
||||||
|
xRatio: startXRatio,
|
||||||
|
yRatio: startYRatio
|
||||||
|
});
|
||||||
|
|
||||||
|
await locator.dispatchEvent('pointerdown', {
|
||||||
|
pointerId: 1,
|
||||||
|
pointerType: 'touch',
|
||||||
|
isPrimary: true,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y
|
||||||
|
});
|
||||||
|
|
||||||
|
const dragSteps = 6;
|
||||||
|
for (let step = 1; step <= dragSteps; step++) {
|
||||||
|
await locator.dispatchEvent('pointermove', {
|
||||||
|
pointerId: 1,
|
||||||
|
pointerType: 'touch',
|
||||||
|
isPrimary: true,
|
||||||
|
clientX: x + (moveByX * step) / dragSteps,
|
||||||
|
clientY: y + (moveByY * step) / dragSteps
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
await locator.dispatchEvent('pointerup', {
|
||||||
|
pointerId: 1,
|
||||||
|
pointerType: 'touch',
|
||||||
|
isPrimary: true,
|
||||||
|
clientX: x + moveByX,
|
||||||
|
clientY: y + moveByY
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectScrollWhileHolding = async (page: Page) => {
|
||||||
|
const upperJaw = page.locator(upperJawSelector);
|
||||||
|
const firstParagraph = page.locator('#description p').first();
|
||||||
|
|
||||||
|
await expect(upperJaw).toBeVisible();
|
||||||
|
await expect(firstParagraph).toBeVisible();
|
||||||
|
|
||||||
|
const getTop = async () =>
|
||||||
|
await firstParagraph.evaluate(el => el.getBoundingClientRect().top);
|
||||||
|
|
||||||
|
const topBefore = await getTop();
|
||||||
|
|
||||||
|
await dragWithHeldTouch(page, firstParagraph, { moveByY: -120 });
|
||||||
|
|
||||||
|
const topDuringDrag = await getTop();
|
||||||
|
|
||||||
|
expect(topDuringDrag).toBeLessThan(topBefore - 20);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectUpperJawDragScroll = async (page: Page) => {
|
||||||
|
const upperJaw = page.locator(upperJawSelector);
|
||||||
|
const firstParagraph = page.locator('#description p').first();
|
||||||
|
|
||||||
|
await expect(upperJaw).toBeVisible();
|
||||||
|
await expect(firstParagraph).toBeVisible();
|
||||||
|
|
||||||
|
const getTop = async () =>
|
||||||
|
await firstParagraph.evaluate(el => el.getBoundingClientRect().top);
|
||||||
|
|
||||||
|
const topBefore = await getTop();
|
||||||
|
|
||||||
|
await dragWithHeldTouch(page, upperJaw, {
|
||||||
|
startXRatio: 0.25,
|
||||||
|
startYRatio: 0.55,
|
||||||
|
moveByY: -140
|
||||||
|
});
|
||||||
|
|
||||||
|
const topAfterDrag = await getTop();
|
||||||
|
|
||||||
|
expect(topAfterDrag).toBeLessThan(topBefore - 20);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
viewport: { width: 393, height: 851 },
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upper jaw scrolls while touch is held on a challenge with example content', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await openChallenge(page, challengeWithExampleUrl);
|
||||||
|
await expect(page.locator('#description details.code-details')).toHaveCount(
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectScrollWhileHolding(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upper jaw scrolls while touch is held on long challenge content', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await openChallenge(page, challengeWithLongContentUrl);
|
||||||
|
await expect(page.locator('#description p')).toHaveCount(4);
|
||||||
|
|
||||||
|
await expectScrollWhileHolding(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upper jaw drag gesture scrolls when drag starts on upper jaw container', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await openChallenge(page, challengeWithLongContentUrl);
|
||||||
|
|
||||||
|
await expectUpperJawDragScroll(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('breadcrumb links and example code dropdown are interactive on mobile', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await openChallenge(page, challengeWithExampleUrl);
|
||||||
|
|
||||||
|
const details = page.locator('#description details.code-details').first();
|
||||||
|
const summary = details.locator('summary.code-details-summary');
|
||||||
|
await expect(details).toBeVisible();
|
||||||
|
await expect(summary).toBeVisible();
|
||||||
|
|
||||||
|
const initiallyOpen = await details.evaluate(el => el.hasAttribute('open'));
|
||||||
|
await summary.tap();
|
||||||
|
await expect
|
||||||
|
.poll(async () => await details.evaluate(el => el.hasAttribute('open')))
|
||||||
|
.toBe(!initiallyOpen);
|
||||||
|
await summary.tap();
|
||||||
|
await expect
|
||||||
|
.poll(async () => await details.evaluate(el => el.hasAttribute('open')))
|
||||||
|
.toBe(initiallyOpen);
|
||||||
|
|
||||||
|
const mobileBreadcrumb = page.getByTestId('breadcrumb-mobile');
|
||||||
|
await expect(mobileBreadcrumb).toBeVisible();
|
||||||
|
await mobileBreadcrumb
|
||||||
|
.getByRole('link', { name: 'Responsive Web Design Certification' })
|
||||||
|
.tap();
|
||||||
|
await expect(page).toHaveURL('/learn/responsive-web-design-v9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('example code horizontal gesture does not vertically scroll the upper jaw', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await openChallenge(page, challengeWithHorizontalExampleCodeUrl);
|
||||||
|
|
||||||
|
const firstParagraph = page.locator('#description p').first();
|
||||||
|
const details = page.locator('#description details.code-details').first();
|
||||||
|
const summary = details.locator('summary.code-details-summary');
|
||||||
|
await expect(details).toBeVisible();
|
||||||
|
|
||||||
|
const isOpen = await details.evaluate(el => el.hasAttribute('open'));
|
||||||
|
if (!isOpen) {
|
||||||
|
await summary.tap();
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeRegion = details.locator('pre[role="region"]').first();
|
||||||
|
await expect(codeRegion).toBeVisible();
|
||||||
|
|
||||||
|
const overflowMetrics = await codeRegion.evaluate(el => ({
|
||||||
|
scrollWidth: el.scrollWidth,
|
||||||
|
clientWidth: el.clientWidth
|
||||||
|
}));
|
||||||
|
expect(overflowMetrics.scrollWidth).toBeGreaterThan(
|
||||||
|
overflowMetrics.clientWidth
|
||||||
|
);
|
||||||
|
|
||||||
|
const topBefore = await firstParagraph.evaluate(
|
||||||
|
el => el.getBoundingClientRect().top
|
||||||
|
);
|
||||||
|
await dragWithHeldTouch(page, codeRegion, {
|
||||||
|
startXRatio: 0.8,
|
||||||
|
startYRatio: 0.5,
|
||||||
|
moveByX: -120,
|
||||||
|
moveByY: 0
|
||||||
|
});
|
||||||
|
const topAfter = await firstParagraph.evaluate(
|
||||||
|
el => el.getBoundingClientRect().top
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Math.abs(topAfter - topBefore)).toBeLessThan(10);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user