Skip to content

Commit 3324905

Browse files
authored
fix: ensure proper editor focus management (sveltejs#202)
Also adds tests for focus management
1 parent 2ef959b commit 3324905

File tree

6 files changed

+202
-19
lines changed

6 files changed

+202
-19
lines changed

content/tutorial/common/src/__client.js

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,34 @@ window.addEventListener('message', async (e) => {
6363
}
6464
});
6565

66-
window.addEventListener('pointerdown', () => {
67-
parent.postMessage(
68-
{
69-
type: 'pointerdown'
70-
},
71-
'*'
72-
);
66+
/**
67+
* The iframe sometimes takes focus control in ways we can't prevent
68+
* while the editor is focussed. Refocus the editor in these cases.
69+
*/
70+
window.addEventListener('focusin', (e) => {
71+
/**
72+
* This condition would only be `true` if the iframe took focus when loaded,
73+
* and `false` in other cases, for example:
74+
* - navigation inside the iframe - for example, if you click a link inside
75+
* the iframe, the `focusin` event will be fired twice, the first time
76+
* `e.target` will be its anchor, the second time `e.target` will be body,
77+
* and `e.relatedTarget` will be its anchor (if `csr = false` in only the
78+
* first `focusin` event will be fired)
79+
* - an element such as input gets focus (either from inside or outside the
80+
* iframe) - for example, if an input inside the iframe gets focus,
81+
* `e.target` will be the input.
82+
*/
83+
if (
84+
e.target.tagName === 'BODY' &&
85+
!e.target.contains(e.relatedTarget)
86+
) {
87+
parent.postMessage(
88+
{
89+
type: 'iframe_took_focus'
90+
},
91+
'*'
92+
);
93+
}
7394
});
7495

7596
function ping() {

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
"dev": "vite dev",
66
"build": "node scripts/create-common-bundle && vite build",
77
"preview": "vite preview",
8+
"test": "playwright test",
89
"check": "svelte-check --tsconfig ./jsconfig.json",
910
"check:watch": "svelte-check --tsconfig ./jsconfig.json --watch",
1011
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. .",
1112
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
1213
},
1314
"devDependencies": {
15+
"@playwright/test": "^1.30.0",
1416
"@sveltejs/adapter-auto": "1.0.0-next.90",
1517
"@sveltejs/adapter-vercel": "1.0.0-next.85",
1618
"@sveltejs/kit": "next",

playwright.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { PlaywrightTestConfig } from '@playwright/test';
2+
3+
const config: PlaywrightTestConfig = {
4+
webServer: {
5+
command: 'pnpm build && pnpm preview',
6+
port: 4173
7+
}
8+
};
9+
10+
export default config;

pnpm-lock.yaml

Lines changed: 20 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/routes/tutorial/[slug]/Editor.svelte

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
let h = 0;
3232
3333
let preserve_editor_focus = false;
34+
/** @type {any} */
35+
let remove_focus_timeout;
3436
3537
onMount(() => {
3638
let destroyed = false;
@@ -237,8 +239,8 @@
237239
}
238240
}}
239241
on:message={(e) => {
240-
if (e.data.type === 'pointerdown') {
241-
preserve_editor_focus = false;
242+
if (preserve_editor_focus && e.data.type === 'iframe_took_focus') {
243+
instance?.editor.focus();
242244
}
243245
}}
244246
/>
@@ -256,15 +258,16 @@
256258
}
257259
}}
258260
on:focusin={() => {
261+
clearTimeout(remove_focus_timeout);
259262
preserve_editor_focus = true;
260263
}}
261-
on:focusout={() => {
262-
// Little timeout, because inner postMessage event might take a little
263-
setTimeout(() => {
264-
if (preserve_editor_focus) {
265-
instance?.editor.focus();
266-
}
267-
}, 100);
264+
on:focusout={(e) => {
265+
// Heuristic: user did refocus themmselves if iframe_took_focus
266+
// doesn't happen in the next few miliseconds. Needed
267+
// because else navigations inside the iframe refocus the editor.
268+
remove_focus_timeout = setTimeout(() => {
269+
preserve_editor_focus = false;
270+
}, 200);
268271
}}
269272
/>
270273
</div>

tests/focus_management.spec.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { expect, test, chromium } from '@playwright/test';
2+
3+
const chromium_flags = ['--enable-features=SharedArrayBuffer'];
4+
5+
const editor_selector = 'div.monaco-scrollable-element.editor-scrollable';
6+
const editor_focus_selector = 'textarea.inputarea.monaco-mouse-cursor-text';
7+
const iframe_selector = 'iframe[src*="webcontainer.io/"]';
8+
9+
test('focus management: the editor keeps focus when iframe is loaded', async () => {
10+
const context = await chromium.launchPersistentContext('', { args: chromium_flags });
11+
const page = context.pages()[0];
12+
await page.bringToFront();
13+
14+
await page.goto('/tutorial/your-first-component');
15+
16+
// first, focus the editor before the iframe is loaded
17+
await page.locator(editor_selector).click({ delay: 1000 });
18+
19+
// at this time, expect focus to be on the editor
20+
await expect(page.locator(editor_focus_selector)).toBeFocused();
21+
22+
// wait for the iframe to load
23+
await page.frameLocator(iframe_selector).getByText('Hello world!').waitFor();
24+
25+
// wait a little, because there may be a script that manipulates focus
26+
await page.waitForTimeout(1000);
27+
28+
// expect focus to be on the editor
29+
await expect(page.locator(editor_focus_selector)).toBeFocused();
30+
31+
await context.close();
32+
});
33+
34+
test('focus management: input inside the iframe gets focus when clicking it', async () => {
35+
const context = await chromium.launchPersistentContext('', { args: chromium_flags });
36+
const page = context.pages()[0];
37+
await page.bringToFront();
38+
39+
await page.goto('/tutorial/named-form-actions');
40+
41+
const iframe = page.frameLocator(iframe_selector);
42+
43+
// wait for the iframe to load
44+
await iframe.getByText('todos').waitFor();
45+
46+
// first, focus the editor
47+
await page.locator(editor_selector).click({ delay: 1000 });
48+
await expect(page.locator(editor_focus_selector)).toBeFocused();
49+
50+
// then, click a input in the iframe
51+
const input = iframe.locator('input[name="description"]');
52+
await input.click({ delay: 500 });
53+
54+
// wait a little, because there may be a script that manipulates focus
55+
await page.waitForTimeout(1000);
56+
57+
// expect focus to be on the input in the iframe, not the editor.
58+
await expect(input).toBeFocused();
59+
await expect(page.locator(editor_focus_selector)).not.toBeFocused();
60+
61+
await context.close();
62+
});
63+
64+
test('focus management: body inside the iframe gets focus when clicking a link inside the iframe', async () => {
65+
const context = await chromium.launchPersistentContext('', { args: chromium_flags });
66+
const page = context.pages()[0];
67+
await page.bringToFront();
68+
69+
await page.goto('/tutorial/layouts');
70+
71+
const iframe = page.frameLocator(iframe_selector);
72+
73+
// wait for the iframe to load
74+
await iframe.getByText('this is the home page.').waitFor();
75+
76+
// first, focus the editor
77+
await page.locator(editor_selector).click({ delay: 1000 });
78+
await expect(page.locator(editor_focus_selector)).toBeFocused();
79+
80+
// then, click a link in the iframe
81+
await iframe.locator('a[href="/about"]').click({ delay: 500 });
82+
83+
// wait for navigation
84+
await iframe.getByText('this is the about page.').waitFor();
85+
86+
// wait a little, because there may be a script that manipulates focus
87+
await page.waitForTimeout(1000);
88+
89+
// expect focus to be on body in the iframe, not the editor.
90+
await expect(iframe.locator('body')).toBeFocused();
91+
92+
await context.close();
93+
});
94+
95+
test('focus management: the editor keeps focus while typing', async () => {
96+
const context = await chromium.launchPersistentContext('', { args: chromium_flags });
97+
const page = context.pages()[0];
98+
await page.bringToFront();
99+
100+
await page.goto('/tutorial/your-first-component');
101+
102+
// wait for the iframe to load
103+
await page.frameLocator(iframe_selector).getByText('Hello world!').waitFor();
104+
105+
// first, write script tag
106+
const code = '<script>\n\n</script>\n';
107+
await page.locator(editor_focus_selector).fill(code);
108+
109+
// move the cursor into the script tag
110+
await page.keyboard.press('PageUp', { delay: 500 });
111+
await page.keyboard.press('ArrowDown', { delay: 500 });
112+
113+
// wait a little because the above operation is flaky
114+
await page.waitForTimeout(500);
115+
116+
// type the code as a person would do it manually
117+
await page.keyboard.type(` export let data;`, { delay: 100 });
118+
119+
// wait a little, because there may be a script that manipulates focus
120+
await page.waitForTimeout(1000);
121+
122+
// get code from DOM, then replace nbsp with normal space
123+
const received = (await page.locator(editor_selector).innerText()).replace(/\u00a0/g, ' ');
124+
125+
const expected = '<script>\n export let data;\n</script>\n<h1>Hello world!</h1>';
126+
127+
expect(received).toBe(expected);
128+
129+
await context.close();
130+
});

0 commit comments

Comments
 (0)