Skip to content

Commit 82ad15e

Browse files
authored
Focus should not blur briefly after tapping on a suggested action (#5097)
* Fix focus should not blur briefly after tapping on a suggested action * Update PR number
1 parent 1c16476 commit 82ad15e

15 files changed

+323
-60
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
2424

2525
### Added
2626

27-
- Resolves [#5081](https://github.com/microsoft/BotFramework-WebChat/issues/5081). Added `uploadAccept` and `uploadMultiple` style options, by [@ms-jb](https://github.com/ms-jb)
27+
- Resolves [#5081](https://github.com/microsoft/BotFramework-WebChat/issues/5081). Added `uploadAccept` and `uploadMultiple` style options, by [@ms-jb](https://github.com/ms-jb)
28+
29+
### Fixed
30+
31+
- Fixes [#5050](https://github.com/microsoft/BotFramework-WebChat/issues/5050). Fixed focus should not blur briefly after tapping on a suggested action, by [@compulim](https://github.com/compulim), in PR [#5097](https://github.com/microsoft/BotFramework-WebChat/issues/pull/5097)
2832

2933
### Changed
3034

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
</head>
9+
<body>
10+
<main id="webchat"></main>
11+
<script>
12+
run(async function () {
13+
WebChat.renderWebChat(
14+
{
15+
directLine: testHelpers.createDirectLineWithTranscript([
16+
{
17+
from: {
18+
id: 'bot',
19+
role: 'bot'
20+
},
21+
suggestedActions: {
22+
actions: [
23+
{
24+
type: 'imBack',
25+
value: 'What can I say?'
26+
},
27+
{
28+
type: 'imBack',
29+
value: 'What is the weather?'
30+
}
31+
]
32+
},
33+
textFormat: 'markdown',
34+
timestamp: new Date(2000, 0, 1, 12, 34, 56, 789).toISOString(),
35+
type: 'message'
36+
}
37+
]),
38+
store: testHelpers.createStore(),
39+
styleOptions: {
40+
suggestedActionLayout: 'stacked'
41+
}
42+
},
43+
document.getElementById('webchat')
44+
);
45+
46+
await pageConditions.uiConnected();
47+
await pageConditions.suggestedActionsShown();
48+
49+
// THEN: Suggested actions container in stacked layout should be of `role="toolbar"` with `aria-orientation="vertical"`
50+
const [firstSuggestedAction] = pageElements.suggestedActions();
51+
52+
let elementBeforeClick;
53+
54+
pageElements.sendBoxTextBox().focus = () => {
55+
elementBeforeClick = document.activeElement;
56+
};
57+
58+
await host.click(firstSuggestedAction);
59+
60+
expect(elementBeforeClick).not.toBe(document.body);
61+
expect(elementBeforeClick).toBe(firstSuggestedAction);
62+
});
63+
</script>
64+
</body>
65+
</html>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */
2+
3+
describe('accessibility requirement', () => {
4+
describe('after clicking on suggested action', () => {
5+
test('should send the focus to send box immediately', () => runHTML('accessibility.suggestedActions.sendFocusImmediately.html'));
6+
});
7+
});

packages/component/package-lock.json

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

packages/component/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"concurrently": "^8.2.2",
9191
"core-js": "^3.34.0",
9292
"node-dev": "^8.0.0",
93+
"type-fest": "^4.14.0",
9394
"typescript": "^5.3.2"
9495
},
9596
"dependencies": {

packages/component/src/Composer.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,44 @@
1+
import createEmotion from '@emotion/css/create-instance';
12
import { Composer as APIComposer, hooks, WebSpeechPonyfillFactory } from 'botframework-webchat-api';
2-
import { Composer as SayComposer } from 'react-say';
33
import { singleToArray } from 'botframework-webchat-core';
44
import classNames from 'classnames';
5-
import createEmotion from '@emotion/css/create-instance';
6-
import createStyleSet from './Styles/createStyleSet';
75
import MarkdownIt from 'markdown-it';
86
import PropTypes from 'prop-types';
97
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
8+
import { Composer as SayComposer } from 'react-say';
9+
import createStyleSet from './Styles/createStyleSet';
1010

11+
import createDefaultAttachmentMiddleware from './Attachment/createMiddleware';
12+
import Dictation from './Dictation';
13+
import ErrorBox from './ErrorBox';
1114
import {
1215
speechSynthesis as bypassSpeechSynthesis,
1316
SpeechSynthesisUtterance as BypassSpeechSynthesisUtterance
1417
} from './hooks/internal/BypassSpeechSynthesisPonyfill';
15-
import ActivityTreeComposer from './providers/ActivityTree/ActivityTreeComposer';
16-
import addTargetBlankToHyperlinksMarkdown from './Utils/addTargetBlankToHyperlinksMarkdown';
17-
import createCSSKey from './Utils/createCSSKey';
18+
import UITracker from './hooks/internal/UITracker';
19+
import WebChatUIContext from './hooks/internal/WebChatUIContext';
20+
import useStyleSet from './hooks/useStyleSet';
1821
import createDefaultActivityMiddleware from './Middleware/Activity/createCoreMiddleware';
1922
import createDefaultActivityStatusMiddleware from './Middleware/ActivityStatus/createCoreMiddleware';
2023
import createDefaultAttachmentForScreenReaderMiddleware from './Middleware/AttachmentForScreenReader/createCoreMiddleware';
21-
import createDefaultAttachmentMiddleware from './Attachment/createMiddleware';
2224
import createDefaultAvatarMiddleware from './Middleware/Avatar/createCoreMiddleware';
2325
import createDefaultCardActionMiddleware from './Middleware/CardAction/createCoreMiddleware';
2426
import createDefaultScrollToEndButtonMiddleware from './Middleware/ScrollToEndButton/createScrollToEndButtonMiddleware';
2527
import createDefaultToastMiddleware from './Middleware/Toast/createCoreMiddleware';
2628
import createDefaultTypingIndicatorMiddleware from './Middleware/TypingIndicator/createCoreMiddleware';
27-
import Dictation from './Dictation';
29+
import ActivityTreeComposer from './providers/ActivityTree/ActivityTreeComposer';
30+
import SendBoxComposer from './providers/internal/SendBox/SendBoxComposer';
31+
import ModalDialogComposer from './providers/ModalDialog/ModalDialogComposer';
32+
import addTargetBlankToHyperlinksMarkdown from './Utils/addTargetBlankToHyperlinksMarkdown';
33+
import createCSSKey from './Utils/createCSSKey';
2834
import downscaleImageToDataURL from './Utils/downscaleImageToDataURL';
29-
import ErrorBox from './ErrorBox';
3035
import mapMap from './Utils/mapMap';
31-
import ModalDialogComposer from './providers/ModalDialog/ModalDialogComposer';
32-
import SendBoxComposer from './providers/internal/SendBox/SendBoxComposer';
33-
import UITracker from './hooks/internal/UITracker';
34-
import useStyleSet from './hooks/useStyleSet';
35-
import WebChatUIContext from './hooks/internal/WebChatUIContext';
3636

3737
import type { ComposerProps as APIComposerProps } from 'botframework-webchat-api';
3838
import type { FC, ReactNode } from 'react';
39+
import type { ContextOf } from './types/ContextOf';
40+
import { type FocusSendBoxInit } from './types/internal/FocusSendBoxInit';
41+
import { type FocusTranscriptInit } from './types/internal/FocusTranscriptInit';
3942

4043
const { useGetActivityByKey, useReferenceGrammarID, useStyleOptions } = hooks;
4144

@@ -97,8 +100,8 @@ const ComposerCore: FC<ComposerCoreProps> = ({
97100
const [dictateAbortable, setDictateAbortable] = useState();
98101
const [referenceGrammarID] = useReferenceGrammarID();
99102
const [styleOptions] = useStyleOptions();
100-
const focusSendBoxCallbacksRef = useRef([]);
101-
const focusTranscriptCallbacksRef = useRef([]);
103+
const focusSendBoxCallbacksRef = useRef<((init: FocusSendBoxInit) => Promise<void>)[]>([]);
104+
const focusTranscriptCallbacksRef = useRef<((init: FocusTranscriptInit) => Promise<void>)[]>([]);
102105
const internalMarkdownIt = useMemo(() => new MarkdownIt(), []);
103106
const scrollToCallbacksRef = useRef([]);
104107
const scrollToEndCallbacksRef = useRef([]);
@@ -202,7 +205,7 @@ const ComposerCore: FC<ComposerCoreProps> = ({
202205
[transcriptFocusObserversRef, setNumTranscriptFocusObservers]
203206
);
204207

205-
const context = useMemo(
208+
const context = useMemo<ContextOf<typeof WebChatUIContext>>(
206209
() => ({
207210
dictateAbortable,
208211
dispatchScrollPosition,

packages/component/src/SendBox/SuggestedAction.tsx

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
import { hooks } from 'botframework-webchat-api';
2+
import type { DirectLineCardAction } from 'botframework-webchat-core';
23
import classNames from 'classnames';
34
import PropTypes from 'prop-types';
45
import React, { MouseEventHandler, useCallback, VFC } from 'react';
5-
import type { DirectLineCardAction } from 'botframework-webchat-core';
66

7-
import AccessibleButton from '../Utils/AccessibleButton';
87
import connectToWebChat from '../connectToWebChat';
9-
import useFocus from '../hooks/useFocus';
10-
import useFocusAccessKeyEffect from '../Utils/AccessKeySink/useFocusAccessKeyEffect';
118
import useFocusVisible from '../hooks/internal/useFocusVisible';
12-
import useItemRef from '../providers/RovingTabIndex/useItemRef';
139
import useLocalizeAccessKey from '../hooks/internal/useLocalizeAccessKey';
14-
import useScrollToEnd from '../hooks/useScrollToEnd';
15-
import useStyleSet from '../hooks/useStyleSet';
1610
import useStyleToEmotionObject from '../hooks/internal/useStyleToEmotionObject';
1711
import useSuggestedActionsAccessKey from '../hooks/internal/useSuggestedActionsAccessKey';
12+
import useFocus from '../hooks/useFocus';
13+
import useScrollToEnd from '../hooks/useScrollToEnd';
14+
import useStyleSet from '../hooks/useStyleSet';
15+
import useItemRef from '../providers/RovingTabIndex/useItemRef';
16+
import AccessibleButton from '../Utils/AccessibleButton';
17+
import useFocusAccessKeyEffect from '../Utils/AccessKeySink/useFocusAccessKeyEffect';
1818

1919
const { useDirection, useDisabled, usePerformCardAction, useStyleOptions, useSuggestedActions } = hooks;
2020

@@ -90,15 +90,21 @@ const SuggestedAction: VFC<SuggestedActionProps> = ({
9090

9191
const handleClick = useCallback<MouseEventHandler<HTMLButtonElement>>(
9292
({ target }) => {
93-
// TODO: [P3] #XXX We should not destruct DirectLineCardAction into React props and pass them in. It makes typings difficult.
94-
// Instead, we should pass a "cardAction" props.
95-
performCardAction({ displayText, text, type, value } as DirectLineCardAction, { target });
93+
(async function () {
94+
// We need to focus to the send box before we are performing this card action.
95+
// The will make sure the focus is always on Web Chat.
96+
// Otherwise, the focus may momentarily send to `document.body` and screen reader will be confused.
97+
await focus('sendBoxWithoutKeyboard');
98+
99+
// TODO: [P3] #XXX We should not destruct DirectLineCardAction into React props and pass them in. It makes typings difficult.
100+
// Instead, we should pass a "cardAction" props.
101+
performCardAction({ displayText, text, type, value } as DirectLineCardAction, { target });
96102

97-
// Since "openUrl" action do not submit, the suggested action buttons do not hide after click.
98-
type === 'openUrl' && setSuggestedActions([]);
103+
// Since "openUrl" action do not submit, the suggested action buttons do not hide after click.
104+
type === 'openUrl' && setSuggestedActions([]);
99105

100-
focus('sendBoxWithoutKeyboard');
101-
scrollToEnd();
106+
scrollToEnd();
107+
})();
102108
},
103109
[displayText, focus, performCardAction, scrollToEnd, setSuggestedActions, text, type, value]
104110
);

packages/component/src/SendBox/TextBox.tsx

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@ import classNames from 'classnames';
33
import PropTypes from 'prop-types';
44
import React, { useCallback, useMemo, useRef } from 'react';
55

6-
import { ie11 } from '../Utils/detectBrowser';
76
import AccessibleInputText from '../Utils/AccessibleInputText';
8-
import AutoResizeTextArea from './AutoResizeTextArea';
97
import navigableEvent from '../Utils/TypeFocusSink/navigableEvent';
8+
import { ie11 } from '../Utils/detectBrowser';
109
import useRegisterFocusSendBox from '../hooks/internal/useRegisterFocusSendBox';
10+
import useStyleToEmotionObject from '../hooks/internal/useStyleToEmotionObject';
1111
import useScrollDown from '../hooks/useScrollDown';
1212
import useScrollUp from '../hooks/useScrollUp';
1313
import useStyleSet from '../hooks/useStyleSet';
14-
import useStyleToEmotionObject from '../hooks/internal/useStyleToEmotionObject';
1514
import useSubmit from '../providers/internal/SendBox/useSubmit';
1615
import withEmoji from '../withEmoji/withEmoji';
16+
import AutoResizeTextArea from './AutoResizeTextArea';
1717

1818
import type { MutableRefObject } from 'react';
1919

@@ -163,8 +163,9 @@ const TextBox = ({ className }) => {
163163
[scrollDown, scrollUp]
164164
);
165165

166-
const focusCallback = useCallback<(options?: { noKeyboard?: boolean }) => void>(
167-
({ noKeyboard } = {}) => {
166+
const focusCallback = useCallback<Parameters<typeof useRegisterFocusSendBox>[0]>(
167+
options => {
168+
const { noKeyboard } = options;
168169
const { current } = inputElementRef;
169170

170171
if (current) {
@@ -178,17 +179,19 @@ const TextBox = ({ className }) => {
178179

179180
current.setAttribute('readonly', 'readonly');
180181

181-
// TODO: [P2] We should update this logic to handle quickly-successive `focusCallback`.
182-
// If a succeeding `focusCallback` is being called, the `setTimeout` should run immediately.
183-
// Or the second `focusCallback` should not set `readonly` to `true`.
184-
setTimeout(() => {
185-
const { current } = inputElementRef;
186-
187-
if (current) {
188-
current.focus();
189-
readOnly ? current.setAttribute('readonly', readOnly) : current.removeAttribute('readonly');
190-
}
191-
}, 0);
182+
options.waitUntil(
183+
(async function () {
184+
// TODO: [P2] We should update this logic to handle quickly-successive `focusCallback`.
185+
// If a succeeding `focusCallback` is being called, the `setTimeout` should run immediately.
186+
// Or the second `focusCallback` should not set `readonly` to `true`.
187+
await new Promise(resolve => setTimeout(resolve, 0));
188+
189+
if (current) {
190+
current.focus();
191+
readOnly ? current.setAttribute('readonly', readOnly) : current.removeAttribute('readonly');
192+
}
193+
})()
194+
);
192195
} else {
193196
current.focus();
194197
}

packages/component/src/hooks/internal/WebChatUIContext.js

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createContext, type MutableRefObject } from 'react';
2+
3+
import { type FocusSendBoxInit } from '../../types/internal/FocusSendBoxInit';
4+
import { type FocusTranscriptInit } from '../../types/internal/FocusTranscriptInit';
5+
6+
export type ContextType = {
7+
focusSendBoxCallbacksRef: MutableRefObject<((init: FocusSendBoxInit) => Promise<void>)[]>;
8+
focusTranscriptCallbacksRef: MutableRefObject<((init: FocusTranscriptInit) => Promise<void>)[]>;
9+
};
10+
11+
const context = createContext<ContextType>(undefined as ContextType);
12+
13+
export default context;

0 commit comments

Comments
 (0)