Skip to content

Commit 3c4d3ea

Browse files
committed
feat: focus without scrolling
1 parent a8e589d commit 3c4d3ea

15 files changed

Lines changed: 83 additions & 86 deletions

File tree

e2e/async.spec.ts

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,9 @@ test.describe('Async Formula', () => {
1818
// Before waiting, cells should be empty or show pending state
1919
expect(await a1.locator('.gs-cell-rendered').textContent()).toBe('');
2020

21-
// Wait for async results to resolve
22-
// Chain: A1 (750ms) → A2 (750ms) → A3 (750ms) → A4 (750ms) = ~3s
23-
// A5, A6, A7 use literal args so they resolve in first cycle (~750ms)
24-
// Adding buffer for render time
25-
await page.waitForTimeout(5000);
21+
// Wait for the slowest cell in the chain (A4) to resolve
22+
// Chain: A1 → A2 → A3 → A4
23+
await expect(a4.locator('.gs-cell-rendered')).toHaveText('360', { timeout: 8000 });
2624

2725
// A1: SUM_DELAY(10, 20) = 30
2826
expect(await a1.locator('.gs-cell-rendered').textContent()).toBe('30');
@@ -75,11 +73,8 @@ test.describe('Async Formula', () => {
7573
// Initially, A1 should be empty
7674
expect(await a1.locator('.gs-cell-rendered').textContent()).toBe('');
7775

78-
// Wait for first async computation (A1 takes 1 second + render time)
79-
await page.waitForTimeout(1500);
80-
81-
// A1 should have computed result
82-
expect(await a1.locator('.gs-cell-rendered').textContent()).toBe('30');
76+
// Wait for first async computation (A1 takes ~750ms)
77+
await expect(a1.locator('.gs-cell-rendered')).toHaveText('30', { timeout: 3000 });
8378

8479
// Click on another cell to trigger re-render without changing A1
8580
await b1.click();
@@ -95,31 +90,24 @@ test.describe('Async Formula', () => {
9590
const a1 = sheet.locator("[data-address='A1']");
9691
const a2 = sheet.locator("[data-address='A2']");
9792

98-
// Wait for first async computation
99-
// A1 takes 1s, then A2 depends on A1 (another 1s) = 2s + buffer
100-
await page.waitForTimeout(3000);
101-
102-
// A2 should depend on A1, initial value: SUM_DELAY(30, 100) = 130
103-
let a2Content = await a2.locator('.gs-cell-rendered').textContent();
104-
expect(a2Content).toBe('130');
93+
// Wait for first async computation: A1 (1s) → A2 (1s)
94+
await expect(a2.locator('.gs-cell-rendered')).toHaveText('130', { timeout: 5000 });
10595

106-
// Change A1 value by double-clicking to enter edit mode
10796
// Change A1 value by clicking and typing into the formula bar to ensure replacement
10897
await a1.click();
10998
const formulaBar = sheet.locator('.gs-formula-bar textarea');
11099
await formulaBar.fill('=SUM_DELAY(40, 50)');
111100
await formulaBar.press('Enter');
112101

113-
// Wait for re-computation: A1 takes 1s, then A2 depends on new A1 (another 1s) = 2s + buffer
114-
await page.waitForTimeout(3000);
102+
// Wait for re-computation: A1 takes 1s, then A2 depends on new A1 (another 1s)
103+
await expect(a1.locator('.gs-cell-rendered')).toHaveText('90', { timeout: 5000 });
115104

116105
// A1 should now be 90 (40 + 50)
117106
const a1NewContent = await a1.locator('.gs-cell-rendered').textContent();
118107
expect(a1NewContent).toBe('90');
119108

120109
// A2 should be updated to SUM_DELAY(90, 100) = 190
121-
const a2NewContent = await a2.locator('.gs-cell-rendered').textContent();
122-
expect(a2NewContent).toBe('190');
110+
await expect(a2.locator('.gs-cell-rendered')).toHaveText('190', { timeout: 5000 });
123111
});
124112

125113
test('should propagate pending through async dependency chain', async ({ page }) => {
@@ -136,15 +124,9 @@ test.describe('Async Formula', () => {
136124
const a4InitialContent = await a4.locator('.gs-cell-rendered').textContent();
137125
expect(a4InitialContent).toBe('');
138126

139-
// Wait for async dependency chain to resolve
140-
// A1 (1s) → A2 (1s) → A3 (1s) → A4 (1s) = 4s + buffer
141-
await page.waitForTimeout(5000);
142-
143-
const a1Content = await a1.locator('.gs-cell-rendered').textContent();
144-
expect(a1Content).toBe('30');
145-
146-
const a4Content = await a4.locator('.gs-cell-rendered').textContent();
147-
expect(a4Content).toBe('360');
127+
// Wait for async dependency chain to resolve: A1 → A2 → A3 → A4
128+
await expect(a1.locator('.gs-cell-rendered')).toHaveText('30', { timeout: 8000 });
129+
await expect(a4.locator('.gs-cell-rendered')).toHaveText('360', { timeout: 8000 });
148130
});
149131

150132
test('should display async error code #ASYNC! when async function throws', async ({ page }) => {
@@ -155,8 +137,7 @@ test.describe('Async Formula', () => {
155137

156138
// A8 contains =SUM_DELAY() with no arguments, which should throw an error
157139
const a8Rendered = a8.locator('.gs-cell-rendered');
158-
// Verify that the error code is displayed after a short wait for React render
159-
await page.waitForTimeout(500);
140+
await expect(a8Rendered).not.toHaveText('', { timeout: 3000 });
160141
const a8Content = await a8Rendered.textContent();
161142
expect(a8Content?.trim()).toBe('#ASYNC!');
162143
});

packages/react-core/src/components/Cell.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Context } from '../store';
1919
import { FormulaError } from '../formula/evaluator';
2020
import { Pending } from '../sentinels';
2121
import { insertRef, isRefInsertable } from '../lib/input';
22+
import { focus } from '../lib/dom';
2223
import { isXSheetFocused } from '../store/helpers';
2324
import type { FC, RefObject } from 'react';
2425
import { isTouching, safePreventDefault } from '../lib/events';
@@ -155,7 +156,7 @@ export const Cell: FC<Props> = memo(({ y, x }) => {
155156
}
156157

157158
table.wire.lastFocused = input;
158-
input.focus();
159+
focus(input);
159160
dispatch(setEditingAddress(''));
160161

161162
if (autofillDraggingTo) {
@@ -184,7 +185,7 @@ export const Cell: FC<Props> = memo(({ y, x }) => {
184185
dispatch(setDragging(false));
185186
if (autofillDraggingTo) {
186187
dispatch(submitAutofill(autofillDraggingTo));
187-
input?.focus();
188+
focus(input);
188189
return false;
189190
}
190191
if (editingAnywhere) {

packages/react-core/src/components/ColumnMenu.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as prevention from '../lib/operation';
1515
import { x2c, p2a } from '../lib/coords';
1616
import { between } from '../lib/spatial';
1717
import { copier, cutter, paster, searcher } from '../store/dispatchers';
18+
import { focus } from '../lib/dom';
1819

1920
const METHOD_LABELS: Record<FilterConditionMethod, string> = {
2021
eq: '=',
@@ -56,7 +57,7 @@ export const ColumnMenu: FC = () => {
5657
const firstValueRef = useCallback(
5758
(node: HTMLInputElement | null) => {
5859
if (node) {
59-
node.focus();
60+
focus(node);
6061
}
6162
},
6263
[x],
@@ -90,7 +91,7 @@ export const ColumnMenu: FC = () => {
9091
}
9192
setPendingAction(null);
9293
dispatch(setColumnMenu(null));
93-
editorRef.current?.focus();
94+
focus(editorRef.current);
9495
};
9596
const currentTable = tableRef.current;
9697
if (currentTable && (currentTable.hasPendingCells() || currentTable.wire.asyncPending.size > 0)) {
@@ -121,7 +122,7 @@ export const ColumnMenu: FC = () => {
121122

122123
const handleClose = useCallback(() => {
123124
dispatch(setColumnMenu(null));
124-
editorRef.current?.focus();
125+
focus(editorRef.current);
125126
}, [dispatch, editorRef]);
126127

127128
const handleApplyLabel = useCallback(() => {
@@ -150,7 +151,7 @@ export const ColumnMenu: FC = () => {
150151
tableReactive: { current: table },
151152
}),
152153
);
153-
editorRef.current?.focus();
154+
focus(editorRef.current);
154155
}, [dispatch, x, label, editorRef, table, store.selectingZone, store.choosing]);
155156

156157
const handleSortAsc = useCallback(() => {
@@ -194,7 +195,7 @@ export const ColumnMenu: FC = () => {
194195
setConditions([{ ...DEFAULT_CONDITION, value: [''] }]);
195196
setMode('or');
196197
dispatch(setColumnMenu(null));
197-
editorRef.current?.focus();
198+
focus(editorRef.current);
198199
}, [dispatch, x, editorRef]);
199200

200201
const handleResetAll = useCallback(() => {
@@ -203,13 +204,13 @@ export const ColumnMenu: FC = () => {
203204
setConditions([{ ...DEFAULT_CONDITION, value: [''] }]);
204205
setMode('or');
205206
dispatch(setColumnMenu(null));
206-
editorRef.current?.focus();
207+
focus(editorRef.current);
207208
}, [dispatch, editorRef]);
208209

209210
const handleCancel = useCallback(() => {
210211
setPendingAction(null);
211212
dispatch(setColumnMenu(null));
212-
editorRef.current?.focus();
213+
focus(editorRef.current);
213214
}, [dispatch, editorRef]);
214215

215216
// Escape key cancels pending action during waiting
@@ -504,7 +505,7 @@ export const ColumnMenu: FC = () => {
504505
if (!insertLeftDisabled) {
505506
dispatch(insertColsLeft({ numCols: numSelectedCols, x, operator: 'USER' }));
506507
dispatch(setColumnMenu(null));
507-
editorRef.current?.focus();
508+
focus(editorRef.current);
508509
}
509510
}}
510511
>
@@ -518,7 +519,7 @@ export const ColumnMenu: FC = () => {
518519
if (!insertRightDisabled) {
519520
dispatch(insertColsRight({ numCols: numSelectedCols, x, operator: 'USER' }));
520521
dispatch(setColumnMenu(null));
521-
editorRef.current?.focus();
522+
focus(editorRef.current);
522523
}
523524
}}
524525
>
@@ -532,7 +533,7 @@ export const ColumnMenu: FC = () => {
532533
if (!removeDisabled) {
533534
dispatch(removeCols({ numCols: numSelectedCols, x, operator: 'USER' }));
534535
dispatch(setColumnMenu(null));
535-
editorRef.current?.focus();
536+
focus(editorRef.current);
536537
}
537538
}}
538539
>

packages/react-core/src/components/Editor.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { Context } from '../store';
4040
import { areaToZone, zoneToArea } from '../lib/spatial';
4141
import * as prevention from '../lib/operation';
4242
import { expandInput, insertTextAtCursor, isFocus, isRefInsertable, resetInput } from '../lib/input';
43+
import { focus } from '../lib/dom';
4344
import { Lexer } from '../formula/evaluator';
4445
import { COLOR_PALETTE } from '../lib/palette';
4546
import { useAutocomplete } from './useAutocomplete';
@@ -146,7 +147,7 @@ export const Editor: FC<Props> = ({ mode }: Props) => {
146147
});
147148

148149
useEffect(() => {
149-
editorRef?.current?.focus?.({ preventScroll: true });
150+
focus(editorRef?.current);
150151
}, [editorRef]);
151152

152153
useEffect(() => {
@@ -202,7 +203,7 @@ export const Editor: FC<Props> = ({ mode }: Props) => {
202203

203204
setTimeout(() => {
204205
if (editorRef.current) {
205-
editorRef.current.focus();
206+
focus(editorRef.current);
206207
editorRef.current.setSelectionRange(newCursor, newCursor);
207208
}
208209
}, 0);
@@ -434,7 +435,7 @@ export const Editor: FC<Props> = ({ mode }: Props) => {
434435
e.preventDefault();
435436
const area = clip(store);
436437
dispatch(copy(areaToZone(area)));
437-
input.focus(); // refocus
438+
focus(input); // refocus
438439
return false;
439440
}
440441
return true;
@@ -448,7 +449,7 @@ export const Editor: FC<Props> = ({ mode }: Props) => {
448449
dispatch(setSearchQuery(''));
449450
}
450451
dispatch(setEntering(false));
451-
requestAnimationFrame(() => searchInputRef.current!.focus());
452+
requestAnimationFrame(() => focus(searchInputRef.current));
452453
return false;
453454
}
454455
}
@@ -498,7 +499,7 @@ export const Editor: FC<Props> = ({ mode }: Props) => {
498499
e.preventDefault();
499500
const area = clip(store);
500501
dispatch(cut(areaToZone(area)));
501-
input.focus(); // refocus
502+
focus(input); // refocus
502503

503504
return false;
504505
}

packages/react-core/src/components/FormulaBar.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { p2a } from '../lib/coords';
88
import { setEditingAddress, setInputting, setEditorHovering, walk, write, updateTable } from '../store/actions';
99
import * as prevention from '../lib/operation';
1010
import { insertTextAtCursor, isFocus } from '../lib/input';
11+
import { focus } from '../lib/dom';
1112
import { editorStyle } from './Editor';
1213
import { ScrollHandle } from './ScrollHandle';
1314
import { useAutocomplete } from './useAutocomplete';
@@ -53,7 +54,7 @@ export const FormulaBar = ({ ready }: FormulaBarProps) => {
5354
dispatch(write({ value }));
5455
}
5556
dispatch(setEditingAddress(''));
56-
editorRef.current!.focus();
57+
focus(editorRef.current);
5758
},
5859
[before],
5960
);
@@ -154,7 +155,7 @@ export const FormulaBar = ({ ready }: FormulaBarProps) => {
154155
dispatch(setInputting(newValue));
155156
setTimeout(() => {
156157
if (largeEditorRef.current) {
157-
largeEditorRef.current.focus();
158+
focus(largeEditorRef.current);
158159
largeEditorRef.current.setSelectionRange(newCursor, newCursor);
159160
}
160161
}, 0);
@@ -186,7 +187,7 @@ export const FormulaBar = ({ ready }: FormulaBarProps) => {
186187
dispatch(setInputting(newValue));
187188
setTimeout(() => {
188189
if (largeEditorRef.current) {
189-
largeEditorRef.current.focus();
190+
focus(largeEditorRef.current);
190191
largeEditorRef.current.setSelectionRange(newCursor, newCursor);
191192
}
192193
}, 0);
@@ -218,7 +219,7 @@ export const FormulaBar = ({ ready }: FormulaBarProps) => {
218219
dispatch(setInputting(before));
219220
dispatch(setEditingAddress(''));
220221
e.preventDefault();
221-
editorRef.current!.focus();
222+
focus(editorRef.current);
222223

223224
break;
224225
}
@@ -272,7 +273,7 @@ export const FormulaBar = ({ ready }: FormulaBarProps) => {
272273
dispatch(setInputting(newValue));
273274
setTimeout(() => {
274275
if (largeEditorRef.current) {
275-
largeEditorRef.current.focus();
276+
focus(largeEditorRef.current);
276277
largeEditorRef.current.setSelectionRange(newCursor, newCursor);
277278
}
278279
}, 0);

packages/react-core/src/components/HeaderCellLeft.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { DEFAULT_HEIGHT } from '../constants';
2121
import * as prevention from '../lib/operation';
2222
import { insertRef } from '../lib/input';
23+
import { focus } from '../lib/dom';
2324
import { isXSheetFocused } from '../store/helpers';
2425
import { ScrollHandle } from './ScrollHandle';
2526
import { isTouching, safePreventDefault } from '../lib/events';
@@ -138,7 +139,7 @@ export const HeaderCellLeft: FC<Props> = memo(({ y }) => {
138139
safePreventDefault(e);
139140
dispatch(setDragging(false));
140141
if (autofillDraggingTo) {
141-
editorRef.current!.focus();
142+
focus(editorRef.current);
142143
return false;
143144
}
144145
},

packages/react-core/src/components/HeaderCellTop.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { DEFAULT_WIDTH } from '../constants';
2121
import * as prevention from '../lib/operation';
2222
import { insertRef } from '../lib/input';
23+
import { focus } from '../lib/dom';
2324
import { isXSheetFocused } from '../store/helpers';
2425
import { ScrollHandle } from './ScrollHandle';
2526
import { isTouching, safePreventDefault } from '../lib/events';
@@ -139,7 +140,7 @@ export const HeaderCellTop: FC<Props> = memo(({ x }) => {
139140
safePreventDefault(e);
140141
dispatch(setDragging(false));
141142
if (autofillDraggingTo) {
142-
editorRef.current!.focus();
143+
focus(editorRef.current);
143144
return false;
144145
}
145146
},

packages/react-core/src/components/Resizer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { DEFAULT_HEIGHT, DEFAULT_WIDTH, MIN_WIDTH, MIN_HEIGHT } from '../constan
88
import { zoneToArea, makeSequence, between } from '../lib/spatial';
99
import type { CellsByAddressType } from '../types';
1010
import { p2a } from '../lib/coords';
11+
import { focus } from '../lib/dom';
1112

1213
export const Resizer = () => {
1314
const { store, dispatch } = useContext(Context);
@@ -74,7 +75,7 @@ export const Resizer = () => {
7475
);
7576
dispatch(setResizingPositionY([-1, -1, -1]));
7677
dispatch(setResizingPositionX([-1, -1, -1]));
77-
editorRef.current!.focus();
78+
focus(editorRef.current);
7879
};
7980
const handleResizeMove = (e: MouseEvent) => {
8081
if (y !== -1) {

0 commit comments

Comments
 (0)