Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 281 additions & 0 deletions e2e/touch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
import { test, expect } from '@playwright/test';
import type { Page, CDPSession } from '@playwright/test';
import { go } from './utils';

/**
* Touch gestures (react-core useTouchGestures) on a phone-sized viewport.
*
* Taps go through page.touchscreen; drags and long presses need raw touch sequences, which
* Playwright only exposes through CDP (Chromium) — fine, since the suite runs on Chromium.
*/
test.use({ viewport: { width: 390, height: 664 }, hasTouch: true, isMobile: true });

type Pt = [number, number];

const center = async (page: Page, selector: string): Promise<Pt> => {
const b = await page.locator(selector).first().boundingBox();
if (!b) throw new Error(`${selector} not found`);
return [b.x + b.width / 2, b.y + b.height / 2];
};

const send = (cdp: CDPSession, type: string, points: Pt[]) =>
cdp.send('Input.dispatchTouchEvent', {
type: type as 'touchStart' | 'touchMove' | 'touchEnd',
touchPoints: points.map(([x, y], id) => ({ x, y, id })),
});

const swipe = async (page: Page, cdp: CDPSession, from: Pt, to: Pt, steps = 10) => {
await send(cdp, 'touchStart', [from]);
for (let i = 1; i <= steps; i++) {
await send(cdp, 'touchMove', [
[from[0] + ((to[0] - from[0]) * i) / steps, from[1] + ((to[1] - from[1]) * i) / steps],
]);
await page.waitForTimeout(16);
}
await send(cdp, 'touchEnd', []);
await page.waitForTimeout(300);
};

const longPress = async (page: Page, cdp: CDPSession, at: Pt) => {
await send(cdp, 'touchStart', [at]);
await page.waitForTimeout(800);
await send(cdp, 'touchEnd', []);
await page.waitForTimeout(300);
};

const tap = async (page: Page, selector: string) => {
await page.touchscreen.tap(...(await center(page, selector)));
await page.waitForTimeout(250);
};

const doubleTap = async (page: Page, selector: string) => {
const at = await center(page, selector);
await page.touchscreen.tap(...at);
await page.waitForTimeout(60);
await page.touchscreen.tap(...at);
await page.waitForTimeout(300);
};

const open = async (page: Page, story: string) => {
await go(page, story);
await page.waitForSelector('.gs-initialized');
await page.waitForTimeout(300);
return page.context().newCDPSession(page);
};

const selectedCount = (page: Page) => page.locator('.gs-cell.gs-selecting').count();

test('tap selects a cell without opening the soft keyboard', async ({ page }) => {
await open(page, 'basic-large--sheet');
await tap(page, "[data-address='B3']");
await expect(page.locator('.gs-cell.gs-choosing')).toHaveAttribute('data-address', 'B3');
await expect(page.locator('.gs-editor textarea')).toHaveAttribute('inputmode', 'none');
});

test('double tap edits the cell with the keyboard enabled', async ({ page }) => {
await open(page, 'basic-simple--sheet');
await doubleTap(page, "[data-address='A3']");
await expect(page.locator('.gs-editor.gs-editing')).toHaveCount(1);
await expect(page.locator('.gs-editor textarea')).not.toHaveAttribute('inputmode', 'none');
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.type('hello');
await page.waitForTimeout(100);
await page.keyboard.press('Enter');
await expect(page.locator("[data-address='A3'] .gs-cell-rendered")).toHaveText('hello');
});

test('tapping a cell while typing a formula inserts its reference', async ({ page }) => {
await open(page, 'basic-simple--sheet');
await doubleTap(page, "[data-address='A4']");
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.type('=SUM(');
await page.waitForTimeout(200);
await tap(page, "[data-address='A1']");
await expect(page.locator('.gs-editor textarea')).toHaveValue('=SUM(A1');
});

test('swiping scrolls without moving the selection', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
const from = await center(page, "[data-address='B8']");
await swipe(page, cdp, from, [from[0] - 150, from[1] - 250], 8);
const top = await page.locator('.gs-tabular').evaluate((el) => el.scrollTop);
expect(top).toBeGreaterThan(100);
await expect(page.locator('.gs-cell.gs-choosing')).toHaveAttribute('data-address', 'A1');
});

test.describe('with an iOS Safari user agent', () => {
// Safari-only code paths (e.g. preventSafariBounce) switch on the UA.
test.use({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
});

test('a swipe that drifts toward an edge already reached still scrolls', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
const from = await center(page, "[data-address='B8']");
// Mostly up, slightly right: at the initial top-left the rightward drift points at the left edge.
await swipe(page, cdp, from, [from[0] + 10, from[1] - 250], 8);
const top = await page.locator('.gs-tabular').evaluate((el) => el.scrollTop);
expect(top).toBeGreaterThan(100);
});
});

test('a mostly vertical swipe is locked to vertical scrolling', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
const tabular = page.locator('.gs-tabular');
const from = await center(page, "[data-address='C10']");
// Up, drifting left the way a thumb does.
const to: Pt = [from[0] - 30, from[1] - 250];
let lockedX = '';
await send(cdp, 'touchStart', [from]);
for (let i = 1; i <= 10; i++) {
await send(cdp, 'touchMove', [[from[0] + ((to[0] - from[0]) * i) / 10, from[1] + ((to[1] - from[1]) * i) / 10]]);
await page.waitForTimeout(16);
if (i === 5) {
// Chromium already rails touch scrolls to an axis, iOS Safari doesn't: check the lock itself.
lockedX = await tabular.evaluate((el) => getComputedStyle(el).overflowX);
}
}
await send(cdp, 'touchEnd', []);
await page.waitForTimeout(500);
expect(lockedX).toBe('hidden');
const after = await tabular.evaluate((el) => [el.scrollLeft, el.scrollTop, getComputedStyle(el).overflowX]);
expect(after[1]).toBeGreaterThan(100);
expect(after[0]).toBe(0);
expect(after[2]).toBe('auto'); // released once scrolling settled
});

test('a diagonal swipe still scrolls both ways', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
const from = await center(page, "[data-address='C10']");
await swipe(page, cdp, from, [from[0] - 150, from[1] - 150], 10);
const [left, top] = await page.locator('.gs-tabular').evaluate((el) => [el.scrollLeft, el.scrollTop]);
expect(top).toBeGreaterThan(50);
expect(left).toBeGreaterThan(50);
});

test('dragging from the cursor cell extends the selection', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
await tap(page, "[data-address='B2']");
await swipe(page, cdp, await center(page, "[data-address='B2']"), await center(page, "[data-address='C5']"));
expect(await selectedCount(page)).toBe(8);
await expect(page.locator('.gs-cell.gs-choosing')).toHaveAttribute('data-address', 'B2');
});

test('holding a range drag at the bottom edge auto-scrolls', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
await tap(page, "[data-address='A3']");
const box = (await page.locator('.gs-tabular').boundingBox())!;
const from = await center(page, "[data-address='A3']");
const to: Pt = [from[0], box.y + box.height - 5];
await send(cdp, 'touchStart', [from]);
for (let i = 1; i <= 10; i++) {
await send(cdp, 'touchMove', [[from[0], from[1] + ((to[1] - from[1]) * i) / 10]]);
await page.waitForTimeout(16);
}
await page.waitForTimeout(600);
await send(cdp, 'touchEnd', []);
await page.waitForTimeout(300);
const top = await page.locator('.gs-tabular').evaluate((el) => el.scrollTop);
expect(top).toBeGreaterThan(100);
// The selection followed the scroll: the rows now in view are selected.
expect(await selectedCount(page)).toBeGreaterThan(10);
});

test('dragging from a selected column header extends the column selection', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
await tap(page, ".gs-th-top[data-x='2'] .gs-th-inner");
await swipe(
page,
cdp,
await center(page, ".gs-th-top[data-x='2'] .gs-th-inner"),
await center(page, ".gs-th-top[data-x='3'] .gs-th-inner"),
);
const cols = await page.evaluate(
() => new Set([...document.querySelectorAll<HTMLElement>('.gs-cell.gs-selecting')].map((e) => e.dataset.x)).size,
);
expect(cols).toBe(2);
});

test('long press on a cell opens the context menu', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
await longPress(page, cdp, await center(page, "[data-address='B3']"));
await expect(page.locator('.gs-context-menu-modal')).toHaveCount(1);
// The pressed cell became the target.
await expect(page.locator('.gs-cell.gs-choosing')).toHaveAttribute('data-address', 'B3');
// The menu opens next to the pressed cell, not under the finger.
const cell = (await page.locator("[data-address='B3']").boundingBox())!;
const menu = (await page.locator('.gs-context-menu').boundingBox())!;
expect(menu.y >= cell.y + cell.height || menu.y + menu.height <= cell.y).toBe(true);
});

test('scrolling the menu never scrolls the page beneath', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
await page.evaluate(() => (document.body.style.paddingBottom = '1500px')); // a scrollable host page
await longPress(page, cdp, await center(page, "[data-address='B3']"));
const m = (await page.locator('.gs-context-menu').boundingBox())!;
const mid: Pt = [m.x + m.width / 2, m.y + m.height / 2];
// Past the menu's own end, then on the backdrop.
for (let i = 0; i < 3; i++) {
await swipe(page, cdp, mid, [mid[0], mid[1] - 200], 8);
}
await swipe(page, cdp, [30, 500], [30, 300], 8);
expect(await page.locator('.gs-context-menu').evaluate((el) => el.scrollTop)).toBeGreaterThan(0);
expect(await page.evaluate(() => scrollY)).toBe(0);
});

test('long press on a column header opens the column menu', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
await longPress(page, cdp, await center(page, ".gs-th-top[data-x='3'] .gs-th-inner"));
await expect(page.locator('.gs-column-menu')).toHaveCount(1);
await expect(page.locator('.gs-context-menu-modal')).toHaveCount(0);
});

test('long press on a row header opens the row menu', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
await longPress(page, cdp, await center(page, ".gs-th-left[data-y='4'] .gs-th-inner"));
await expect(page.locator('.gs-row-menu')).toHaveCount(1);
});

test('dragging a column resizer resizes the column', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
const th = page.locator(".gs-th-top[data-x='2']");
const before = (await th.boundingBox())!.width;
const r = (await page.locator(".gs-th-top[data-x='2'] .gs-resizer").boundingBox())!;
const from: Pt = [r.x + r.width / 2, r.y + r.height / 2];
await swipe(page, cdp, from, [from[0] + 60, from[1]]);
const after = (await th.boundingBox())!.width;
expect(after - before).toBeGreaterThan(40);
});

test('dragging the autofill handle fills', async ({ page }) => {
const cdp = await open(page, 'basic-large--sheet');
await tap(page, "[data-address='A2']");
const h = (await page.locator('.gs-cell.gs-choosing .gs-autofill-drag').boundingBox())!;
await swipe(page, cdp, [h.x + h.width / 2, h.y + h.height / 2], await center(page, "[data-address='A5']"));
for (const a of ['A3', 'A4', 'A5']) {
await expect(page.locator(`[data-address='${a}'] .gs-cell-rendered`)).toHaveText('2');
}
});

test('tapping a checkbox inside a cell still toggles it', async ({ page }) => {
await open(page, 'basic-sortfilter--sheet');
const cb = page.locator("[data-address='A2'] input[type=checkbox]");
const before = await cb.isChecked();
const b = (await cb.boundingBox())!;
await page.touchscreen.tap(b.x + b.width / 2, b.y + b.height / 2);
await page.waitForTimeout(300);
expect(await cb.isChecked()).toBe(!before);
});

test('inputs are at least 16px on touch devices (no iOS focus zoom)', async ({ page }) => {
await open(page, 'basic-large--sheet');
const sizes = await page.evaluate(() =>
['.gs-editor textarea', '.gs-formula-bar textarea'].map((s) =>
parseFloat(getComputedStyle(document.querySelector(s)!).fontSize),
),
);
for (const s of sizes) {
expect(s).toBeGreaterThanOrEqual(16);
}
});
13 changes: 0 additions & 13 deletions packages/react-core/src/components/Cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,18 +153,6 @@ export const Cell: FC<Props> = memo(({ y: rowY, x: colX, merge }) => {
return false;
}

// Single cell selection only for touch events
if (e.type.startsWith('touch')) {
// Blur the input field to commit current value when selecting via touch
if (editingAnywhere && input) {
input.blur();
}
dispatch(choose({ y, x }));
dispatch(select({ startY: y, startX: x, endY: y, endX: x }));
return true;
}

// Normal drag operation for mouse events
if (e.shiftKey) {
dispatch(drag({ y, x }));
} else {
Expand Down Expand Up @@ -417,7 +405,6 @@ export const Cell: FC<Props> = memo(({ y: rowY, x: colX, merge }) => {
className={`gs-cell-inner-wrap`}
style={mergeWrapStyle}
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
onMouseEnter={handleDragging}
onMouseUp={handleDragEnd}
>
Expand Down
1 change: 1 addition & 0 deletions packages/react-core/src/components/ColumnMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const ColumnMenu: FC = () => {

return (
<Fixed
themed
className="gs-menu-modal gs-column-menu-modal"
onClick={(e: MouseEvent) => {
e.preventDefault();
Expand Down
47 changes: 44 additions & 3 deletions packages/react-core/src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,56 @@ import { buildMenuContext } from '../lib/menu';
import { MenuNodes, type MenuNode } from './MenuNodes';
import { clampPopup } from '@gridsheet/web';

const GAP = 6;
const MARGIN = 8;

// Put the menu right below the anchor, or right above it when it doesn't fit there. When it
// fits on neither side it goes on the roomier one and scrolls, rather than covering the anchor
// (on touch, the finger that opened it). Idempotent: it runs after every render.
const placeAroundAnchor = (el: HTMLElement, anchorTop: number, anchorBottom: number) => {
el.style.maxHeight = '';
el.style.overflowY = '';
el.style.boxSizing = '';
const height = el.getBoundingClientRect().height;
const below = window.innerHeight - MARGIN - (anchorBottom + GAP);
const above = anchorTop - GAP - MARGIN;
let top = anchorBottom + GAP;
let maxHeight = 0;
if (height > below) {
if (height <= above) {
top = anchorTop - GAP - height;
} else if (above > below) {
top = MARGIN;
maxHeight = above;
} else {
maxHeight = below;
}
}
el.style.top = `${top}px`;
if (maxHeight) {
// Include the padding / border, or the menu outgrows the room and the edge clamp
// pushes it back over the anchor.
el.style.boxSizing = 'border-box';
el.style.maxHeight = `${maxHeight}px`;
el.style.overflowY = 'auto';
}
};

export const ContextMenu = () => {
const { store, dispatch } = useContext(Context);
const { contextMenuPosition, contextMenu } = store;
const { y: top, x: left } = contextMenuPosition;
const { y: top, x: left, anchorTop, anchorBottom } = contextMenuPosition;
const menuRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (menuRef.current) {
clampPopup(menuRef.current);
const el = menuRef.current;
if (!el) {
return;
}
if (anchorTop != null && anchorBottom != null) {
placeAroundAnchor(el, anchorTop, anchorBottom);
}
clampPopup(el);
});

if (top === -1) {
Expand All @@ -30,6 +70,7 @@ export const ContextMenu = () => {

return (
<Fixed
themed
className="gs-menu-modal gs-context-menu-modal"
onClick={(e: MouseEvent) => {
e.preventDefault();
Expand Down
3 changes: 3 additions & 0 deletions packages/react-core/src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,9 @@ export const Editor: FC<Props> = ({ mode }: Props) => {
autoFocus={true}
spellCheck={false}
draggable={false}
// No soft keyboard for a plain tap on a touch device; a double tap starts editing
// and lifts this (see useTouchGestures). Desktop typing is unaffected.
inputMode={editing ? undefined : 'none'}
ref={editorRef}
rows={numLines}
onFocus={handleFocus}
Expand Down
Loading
Loading