Skip to content

Commit a6cb416

Browse files
committed
Introduce waitForFocus DOM Helper
Convinient helper that resolves when a target receives focus. Useful for verifying keyboard navigation handling and default focus. Uses the pull based waitUntil helper to support the element not being in the DOM when invoking the helper. Alternatives without this helper is asserting `document.activeElement` is the target. It usually work, but there are cases it focus may happne async. The element isn't in view yet.
1 parent a74b4d4 commit a6cb416

File tree

4 files changed

+231
-19
lines changed

4 files changed

+231
-19
lines changed

addon/src/dom/wait-for-focus.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import waitUntil from '../wait-until.ts';
2+
import getElement from './-get-element.ts';
3+
import {
4+
type IDOMElementDescriptor,
5+
lookupDescriptorData,
6+
} from 'dom-element-descriptors';
7+
import getDescription from './-get-description.ts';
8+
9+
export interface Options {
10+
timeout?: number;
11+
count?: number | null;
12+
timeoutMessage?: string;
13+
}
14+
15+
/**
16+
Used to wait for a particular selector to receive focus. Useful for verifying
17+
keyboard navigation handling and default focus behaviour, without having to
18+
think about timing issues.
19+
20+
@param {string|IDOMElementDescriptor} target the selector or DOM element descriptor to wait receiving focus
21+
@param {Object} [options] the options to be used
22+
@param {number} [options.timeout=1000] the time to wait (in ms) for a match
23+
@param {string} [options.timeoutMessage='waitForFocus timed out waiting for selector'] the message to use in the reject on timeout
24+
@return {Promise<Element>} resolves when the element received focus
25+
26+
@example
27+
<caption>
28+
Waiting until a selector receive focus:
29+
</caption>
30+
await waitForFocus('.my-selector', { timeout: 2000 })
31+
*/
32+
export default function waitForFocus(
33+
target: string | IDOMElementDescriptor,
34+
options: Options = {},
35+
): Promise<Element> {
36+
return Promise.resolve().then(() => {
37+
if (typeof target !== 'string' && !lookupDescriptorData(target)) {
38+
throw new Error(
39+
'Must pass a selector or DOM element descriptor to `waitFor`.',
40+
);
41+
}
42+
43+
const { timeout = 1000 } = options;
44+
let { timeoutMessage } = options;
45+
46+
if (!timeoutMessage) {
47+
const description = getDescription(target);
48+
timeoutMessage = `waitForFocus timed out waiting for selector "${description}"`;
49+
}
50+
51+
return waitUntil(
52+
() => {
53+
const element = getElement(target);
54+
if (element && element === document.activeElement) {
55+
return document.activeElement as HTMLElement;
56+
}
57+
},
58+
{ timeout, timeoutMessage },
59+
);
60+
});
61+
}

addon/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export { default as find } from './dom/find.ts';
7474
export { default as findAll } from './dom/find-all.ts';
7575
export { default as typeIn } from './dom/type-in.ts';
7676
export { default as scrollTo } from './dom/scroll-to.ts';
77+
export { default as waitForFocus } from './dom/wait-for-focus.ts';
78+
7779
export type { Target } from './dom/-target.ts';
7880

7981
// Declaration-merge for our internal purposes.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { module, test } from 'qunit';
2+
import {
3+
waitForFocus,
4+
setupContext,
5+
teardownContext,
6+
find,
7+
} from '@ember/test-helpers';
8+
import hasEmberVersion from '@ember/test-helpers/has-ember-version';
9+
import { registerDescriptorData } from 'dom-element-descriptors';
10+
11+
module('DOM Helper: waitForFocus', function (hooks) {
12+
if (!hasEmberVersion(2, 4)) {
13+
return;
14+
}
15+
16+
let context, rootElement;
17+
18+
hooks.beforeEach(function () {
19+
context = {};
20+
rootElement = document.getElementById('ember-testing');
21+
});
22+
23+
hooks.afterEach(async function () {
24+
// only teardown if setupContext was called
25+
if (context.owner) {
26+
await teardownContext(context);
27+
}
28+
document.getElementById('ember-testing').innerHTML = '';
29+
});
30+
31+
class SelectorData {
32+
constructor(selector) {
33+
this.selector = selector;
34+
}
35+
36+
get elements() {
37+
return rootElement.querySelectorAll(this.selector);
38+
}
39+
}
40+
41+
class SelectorDescriptor {
42+
constructor(selector) {
43+
registerDescriptorData(this, new SelectorData(selector));
44+
}
45+
}
46+
47+
test('wait for selector without context set', async function (assert) {
48+
assert.rejects(
49+
waitForFocus('.something'),
50+
/Must setup rendering context before attempting to interact with elements/
51+
);
52+
});
53+
54+
test('wait for focus using descriptor without context set', async function (assert) {
55+
assert.rejects(
56+
waitForFocus('.something'),
57+
/Must setup rendering context before attempting to interact with elements/
58+
);
59+
});
60+
61+
test('wait for focus using descriptor', async function (assert) {
62+
rootElement.innerHTML = `<input class="something">`;
63+
await setupContext(context);
64+
65+
let waitPromise = waitForFocus(new SelectorDescriptor('.something'));
66+
67+
setTimeout(() => {
68+
find('.something').focus();
69+
}, 10);
70+
71+
let element = await waitPromise;
72+
73+
assert.ok(element, 'returns element');
74+
assert.equal(element, find('.something'));
75+
});
76+
77+
test('resolves when the element is already focused', async function (assert) {
78+
rootElement.innerHTML = `<input class="something">`;
79+
await setupContext(context);
80+
81+
find('.something').focus();
82+
83+
let waitPromise = waitForFocus('.something');
84+
let element = await waitPromise;
85+
86+
assert.ok(element, 'returns element');
87+
assert.equal(element, find('.something'));
88+
});
89+
90+
test('wait for focus using selector', async function (assert) {
91+
rootElement.innerHTML = `<input class="something">`;
92+
93+
await setupContext(context);
94+
95+
let waitPromise = waitForFocus('.something');
96+
97+
setTimeout(() => {
98+
find('.something').focus();
99+
}, 10);
100+
101+
let element = await waitPromise;
102+
103+
assert.ok(element, 'returns element');
104+
assert.equal(element, find('.something'));
105+
});
106+
107+
test('wait for selector with timeout', async function (assert) {
108+
assert.expect(2);
109+
110+
await setupContext(context);
111+
112+
let start = Date.now();
113+
try {
114+
await waitForFocus('.something', { timeout: 100 });
115+
} catch (error) {
116+
let end = Date.now();
117+
assert.ok(end - start >= 100, 'timed out after correct time');
118+
assert.equal(
119+
error.message,
120+
'waitForFocus timed out waiting for selector ".something"'
121+
);
122+
}
123+
});
124+
125+
test('wait for selector with timeoutMessage', async function (assert) {
126+
assert.expect(1);
127+
128+
await setupContext(context);
129+
130+
try {
131+
await waitForFocus('.something', {
132+
timeoutMessage: '.something timed out',
133+
});
134+
} catch (error) {
135+
assert.equal(error.message, '.something timed out');
136+
}
137+
});
138+
});

type-tests/api.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expectTypeOf } from 'expect-type';
1+
import { expectTypeOf } from "expect-type";
22

33
import {
44
// DOM Interaction Helpers
@@ -28,6 +28,7 @@ import {
2828
clearRender,
2929
// Wait Helpers
3030
waitFor,
31+
waitForFocus,
3132
waitUntil,
3233
settled,
3334
isSettled,
@@ -73,12 +74,12 @@ import {
7374
type Hook,
7475
type HookLabel,
7576
type HookUnregister,
76-
} from '@ember/test-helpers';
77-
import type { Owner } from '@ember/test-helpers/build-owner';
78-
import type { DebugInfo as BackburnerDebugInfo } from '@ember/runloop/-private/backburner';
79-
import type { Resolver as EmberResolver } from '@ember/owner';
80-
import Application from '@ember/application';
81-
import type { IDOMElementDescriptor } from 'dom-element-descriptors';
77+
} from "@ember/test-helpers";
78+
import type { Owner } from "@ember/test-helpers/build-owner";
79+
import type { DebugInfo as BackburnerDebugInfo } from "@ember/runloop/-private/backburner";
80+
import type { Resolver as EmberResolver } from "@ember/owner";
81+
import Application from "@ember/application";
82+
import type { IDOMElementDescriptor } from "dom-element-descriptors";
8283

8384
// DOM Interaction Helpers
8485
expectTypeOf(blur).toEqualTypeOf<(target?: Target) => Promise<void>>();
@@ -123,13 +124,13 @@ expectTypeOf(triggerEvent).toEqualTypeOf<
123124
target: Target,
124125
eventType: string,
125126
options?: Record<string, unknown>,
126-
force?: boolean,
127+
force?: boolean
127128
) => Promise<void>
128129
>();
129130
expectTypeOf(triggerKeyEvent).toEqualTypeOf<
130131
(
131132
target: Target,
132-
eventType: 'keydown' | 'keyup' | 'keypress',
133+
eventType: "keydown" | "keyup" | "keypress",
133134
key: number | string,
134135
modifiers?: {
135136
ctrlKey?: boolean;
@@ -150,16 +151,16 @@ expectTypeOf(typeIn).toEqualTypeOf<
150151
>();
151152

152153
// DOM Query Helpers
153-
expectTypeOf(find).toEqualTypeOf<Document['querySelector']>();
154-
expectTypeOf(find('a')).toEqualTypeOf<HTMLAnchorElement | SVGAElement | null>();
155-
expectTypeOf(find('div')).toEqualTypeOf<HTMLDivElement | null>();
156-
expectTypeOf(find('circle')).toEqualTypeOf<SVGCircleElement | null>();
157-
expectTypeOf(find('.corkscrew')).toEqualTypeOf<Element | null>();
154+
expectTypeOf(find).toEqualTypeOf<Document["querySelector"]>();
155+
expectTypeOf(find("a")).toEqualTypeOf<HTMLAnchorElement | SVGAElement | null>();
156+
expectTypeOf(find("div")).toEqualTypeOf<HTMLDivElement | null>();
157+
expectTypeOf(find("circle")).toEqualTypeOf<SVGCircleElement | null>();
158+
expectTypeOf(find(".corkscrew")).toEqualTypeOf<Element | null>();
158159
expectTypeOf(findAll).toEqualTypeOf<(selector: string) => Array<Element>>();
159-
expectTypeOf(findAll('a')).toEqualTypeOf<(HTMLAnchorElement | SVGAElement)[]>();
160-
expectTypeOf(findAll('div')).toEqualTypeOf<HTMLDivElement[]>();
161-
expectTypeOf(findAll('circle')).toEqualTypeOf<SVGCircleElement[]>();
162-
expectTypeOf(findAll('.corkscrew')).toEqualTypeOf<Element[]>();
160+
expectTypeOf(findAll("a")).toEqualTypeOf<(HTMLAnchorElement | SVGAElement)[]>();
161+
expectTypeOf(findAll("div")).toEqualTypeOf<HTMLDivElement[]>();
162+
expectTypeOf(findAll("circle")).toEqualTypeOf<SVGCircleElement[]>();
163+
expectTypeOf(findAll(".corkscrew")).toEqualTypeOf<Element[]>();
163164
expectTypeOf(getRootElement).toEqualTypeOf<() => Element | Document>();
164165

165166
// Routing Helpers
@@ -187,9 +188,19 @@ expectTypeOf(waitFor).toEqualTypeOf<
187188
}
188189
) => Promise<Element | Array<Element>>
189190
>();
191+
expectTypeOf(waitForFocus).toEqualTypeOf<
192+
(
193+
selector: string | IDOMElementDescriptor,
194+
options?: {
195+
timeout?: number;
196+
timeoutMessage?: string;
197+
}
198+
) => Promise<Element>
199+
>();
200+
190201
expectTypeOf(waitUntil).toEqualTypeOf<
191202
<T>(
192-
callback: () => T | void | false | 0 | '' | null | undefined,
203+
callback: () => T | void | false | 0 | "" | null | undefined,
193204
options?: {
194205
timeout?: number;
195206
timeoutMessage?: string;

0 commit comments

Comments
 (0)