Skip to content

Commit 13b0b33

Browse files
authored
iOS 26.3: Pause between inputmode="none" and focus() (#5757)
* Pause between inputmode="none" and focus() * Add test * Add PR number * Fix tests * Fix flaky test
1 parent f3fdf3e commit 13b0b33

File tree

7 files changed

+124
-16
lines changed

7 files changed

+124
-16
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ Breaking changes in this release:
391391
- Downgraded graph upsert conflict checks, by [@compulim](https://github.com/compulim) in PR [#5674](https://github.com/microsoft/BotFramework-WebChat/pull/5674)
392392
- Fixed virtual keyboard should show up on tap after being suppressed, in iOS 26.2, by [@compulim](https://github.com/compulim) in PR [#5678](https://github.com/microsoft/BotFramework-WebChat/pull/5678)
393393
- Fixed compatibility with `create-react-app` by adding file extension to `core-js` imports, by [@compulim](https://github.com/compulim) in PR [#5680](https://github.com/microsoft/BotFramework-WebChat/pull/5680)
394+
- Fixed virtual keyboard should be collapsed after being suppressed, in iOS 26.3, by [@compulim](https://github.com/compulim) in PR [#5757](https://github.com/microsoft/BotFramework-WebChat/pull/5757)
394395

395396
## [4.18.0] - 2024-07-10
396397

__tests__/html2/fluentTheme/connectivityStatus.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
} = window; // Imports in UMD fashion.
2222

2323
const { directLine, store } = testHelpers.createDirectLineEmulator({ autoConnect: false });
24+
const styleOptions = { spinnerAnimationBackgroundImage: 'url(/assets/staticspinner.png)' };
2425

25-
const App = () => <ReactWebChat directLine={directLine} store={store} />;
26+
const App = () => <ReactWebChat directLine={directLine} store={store} styleOptions={styleOptions} />;
2627

2728
render(
2829
<FluentThemeProvider>
-14 Bytes
Loading

__tests__/html2/hooks/useFocus.sendBox.pure.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,13 @@
3939

4040
await renderHook();
4141

42-
expect(document.activeElement).not.toEqual(pageElements.sendBoxTextBox());
42+
expect(document.activeElement === pageElements.sendBoxTextBox()).toBe(false);
4343

4444
const focus = await renderHook(() => useFocus());
4545

46-
focus('sendBox');
46+
await focus('sendBox');
4747

48-
expect(document.activeElement).toEqual(pageElements.sendBoxTextBox());
48+
expect(document.activeElement === pageElements.sendBoxTextBox()).toBe(true);
4949
});
5050
</script>
5151
</body>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<!doctype html>
2+
<html lang="en-US">
3+
<head>
4+
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
5+
<script crossorigin="anonymous" src="/test-harness.js"></script>
6+
<script crossorigin="anonymous" src="/test-page-object.js"></script>
7+
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
8+
<script type="importmap">
9+
{
10+
"imports": {
11+
"jest-mock": "https://esm.sh/jest-mock"
12+
}
13+
}
14+
</script>
15+
</head>
16+
<body>
17+
<main id="webchat"></main>
18+
<script type="module">
19+
import { fn, spyOn } from 'jest-mock';
20+
21+
run(async function () {
22+
const {
23+
testHelpers: { createDirectLineEmulator }
24+
} = window;
25+
26+
const { directLine, store } = createDirectLineEmulator();
27+
28+
const timeline = [];
29+
30+
const originalRequestIdleCallback = window.requestIdleCallback;
31+
32+
const requestIdleCallback = spyOn(window, 'requestIdleCallback').mockImplementation(callback => {
33+
timeline.push('requestIdleCallback()');
34+
originalRequestIdleCallback.call(window, callback);
35+
});
36+
37+
WebChat.renderWebChat({ directLine, store }, document.getElementById('webchat'));
38+
39+
await pageConditions.uiConnected();
40+
41+
await directLine.actPostActivity(async () => {
42+
const sendBoxTextBox = pageElements.sendBoxTextBox();
43+
44+
const originalFocus = sendBoxTextBox.focus;
45+
const originalSetAttribute = sendBoxTextBox.setAttribute;
46+
47+
const focus = spyOn(sendBoxTextBox, 'focus').mockImplementation(() => {
48+
timeline.push('focus()');
49+
originalFocus.call(sendBoxTextBox);
50+
});
51+
52+
const setAttribute = spyOn(sendBoxTextBox, 'setAttribute').mockImplementation((name, value) => {
53+
timeline.push(`setAttribute(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
54+
originalSetAttribute.call(sendBoxTextBox, name, value);
55+
});
56+
57+
await host.click(pageElements.sendBoxTextBox());
58+
await host.sendKeys('Hello, World!');
59+
60+
// WHEN: Click on the send button.
61+
await host.click(pageElements.sendButton());
62+
63+
expect(timeline).toEqual([
64+
'setAttribute(\"inputmode\", \"text\")', // THEN: `setAttribute()` is called when click on the text box.
65+
'setAttribute(\"inputmode\", \"none\")', // THEN: Tap on the send button should hide the virtual keyboard.
66+
'requestIdleCallback()', // THEN: Make sure there is a pause between `setAttribute()` and `focus()`
67+
'focus()' // THEN: Should focus on the send box.
68+
]);
69+
70+
expect(document.activeElement).toBe(sendBoxTextBox);
71+
});
72+
});
73+
</script>
74+
</body>
75+
</html>

__tests__/html2/transcript/navigation/useObserveTranscriptFocus.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<!DOCTYPE html>
1+
<!doctype html>
22
<html lang="en-US">
33
<head>
44
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
@@ -75,7 +75,7 @@
7575
// THEN: It should send a "transcriptfocus" event with the third-last activity, which is a card activity (#29).
7676
expect(transcriptFocusActivityIDHistory).toEqual(['31', '30', '29']);
7777

78-
// WHEN: Pressing ENTER key while focusingo on the card activity (#29).
78+
// WHEN: Pressing ENTER key while focusing on the card activity (#29).
7979
await host.sendKeys('ENTER');
8080

8181
// THEN: It should not send another event because the transcript did not gain any new focus.

packages/component/src/SendBox/TextBox.tsx

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { hooks } from 'botframework-webchat-api';
2+
import { usePonyfill } from 'botframework-webchat-api/hook';
23
import classNames from 'classnames';
34
import React, { useCallback, useMemo, useRef } from 'react';
45

56
import AccessibleInputText from '../Utils/AccessibleInputText';
67
import navigableEvent from '../Utils/TypeFocusSink/navigableEvent';
7-
import { useRegisterFocusSendBox, type SendBoxFocusOptions } from '../hooks/sendBoxFocus';
88
import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject';
9+
import { useRegisterFocusSendBox, type SendBoxFocusOptions } from '../hooks/sendBoxFocus';
910
import useScrollDown from '../hooks/useScrollDown';
1011
import useScrollUp from '../hooks/useScrollUp';
1112
import useStyleSet from '../hooks/useStyleSet';
@@ -164,17 +165,47 @@ const TextBox = ({ className = '' }: Readonly<{ className?: string | undefined }
164165
[scrollDown, scrollUp]
165166
);
166167

168+
const [{ requestAnimationFrame, requestIdleCallback }] = usePonyfill();
169+
const requestIdleCallbackWithPonyfill = useMemo(
170+
() => requestIdleCallback ?? ((callback: () => void) => requestAnimationFrame(callback)),
171+
[requestAnimationFrame, requestIdleCallback]
172+
);
173+
167174
const focusCallback = useCallback(
168-
({ noKeyboard }: SendBoxFocusOptions) => {
169-
const { current } = inputElementRef;
170-
171-
// Setting `inputMode` to `none` temporarily to suppress soft keyboard in iOS.
172-
// We will revert the change once the end-user tap on the send box.
173-
// This code path is only triggered when the user press "send" button to send the message, instead of pressing ENTER key.
174-
noKeyboard && current?.setAttribute('inputmode', 'none');
175-
current?.focus();
175+
({ noKeyboard, waitUntil }: SendBoxFocusOptions) => {
176+
waitUntil(
177+
(async () => {
178+
const { current } = inputElementRef;
179+
180+
if (current) {
181+
// Setting `inputMode` to `none` temporarily to suppress soft keyboard in iOS.
182+
// We will revert the change once the end-user tap on the send box.
183+
// This code path is only triggered when the user press "send" button to send the message, instead of pressing ENTER key.
184+
if (noKeyboard) {
185+
if (current.getAttribute('inputmode') !== 'none') {
186+
// Collapse the virtual keybaord if it was expanded.
187+
current.setAttribute('inputmode', 'none');
188+
189+
// iOS 26.3 quirks: `HTMLElement.focus()` does not pickup `inputmode="none"` changes immediately.
190+
// We need to wait for next frame before calling `focus()`.
191+
// This is a regression from iOS 26.2.
192+
await new Promise<void>(resolve => requestIdleCallbackWithPonyfill(resolve));
193+
}
194+
} else if (current.hasAttribute('inputmode')) {
195+
// Expanding the virtual keyboard if it was collapsed.
196+
// However, we are not pausing here to workaround iOS 26.3 quirks.
197+
// If we pause here, it will not able to handle this scenario: focus on an activity on the transcript, press A, the letter A should be inputted into the send box.
198+
// In other words, if we pause here, the event will be send to the activity/transcript, instead of the newly focused send box.
199+
// This is related to BasicTranscript.handleTranscriptKeyDownCapture().
200+
current.removeAttribute('inputmode');
201+
}
202+
203+
current?.focus();
204+
}
205+
})()
206+
);
176207
},
177-
[inputElementRef]
208+
[inputElementRef, requestIdleCallbackWithPonyfill]
178209
);
179210

180211
useRegisterFocusSendBox(focusCallback);

0 commit comments

Comments
 (0)