Skip to content

Commit

Permalink
Focus should not blur briefly after tapping on a suggested action (#5097
Browse files Browse the repository at this point in the history
)

* Fix focus should not blur briefly after tapping on a suggested action

* Update PR number
  • Loading branch information
compulim authored Mar 28, 2024
1 parent 1c16476 commit 82ad15e
Show file tree
Hide file tree
Showing 15 changed files with 323 additions and 60 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### Added

- Resolves [#5081](https://github.com/microsoft/BotFramework-WebChat/issues/5081). Added `uploadAccept` and `uploadMultiple` style options, by [@ms-jb](https://github.com/ms-jb)
- Resolves [#5081](https://github.com/microsoft/BotFramework-WebChat/issues/5081). Added `uploadAccept` and `uploadMultiple` style options, by [@ms-jb](https://github.com/ms-jb)

### Fixed

- 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)

### Changed

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script>
run(async function () {
WebChat.renderWebChat(
{
directLine: testHelpers.createDirectLineWithTranscript([
{
from: {
id: 'bot',
role: 'bot'
},
suggestedActions: {
actions: [
{
type: 'imBack',
value: 'What can I say?'
},
{
type: 'imBack',
value: 'What is the weather?'
}
]
},
textFormat: 'markdown',
timestamp: new Date(2000, 0, 1, 12, 34, 56, 789).toISOString(),
type: 'message'
}
]),
store: testHelpers.createStore(),
styleOptions: {
suggestedActionLayout: 'stacked'
}
},
document.getElementById('webchat')
);

await pageConditions.uiConnected();
await pageConditions.suggestedActionsShown();

// THEN: Suggested actions container in stacked layout should be of `role="toolbar"` with `aria-orientation="vertical"`
const [firstSuggestedAction] = pageElements.suggestedActions();

let elementBeforeClick;

pageElements.sendBoxTextBox().focus = () => {
elementBeforeClick = document.activeElement;
};

await host.click(firstSuggestedAction);

expect(elementBeforeClick).not.toBe(document.body);
expect(elementBeforeClick).toBe(firstSuggestedAction);
});
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */

describe('accessibility requirement', () => {
describe('after clicking on suggested action', () => {
test('should send the focus to send box immediately', () => runHTML('accessibility.suggestedActions.sendFocusImmediately.html'));
});
});
19 changes: 19 additions & 0 deletions packages/component/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/component/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"concurrently": "^8.2.2",
"core-js": "^3.34.0",
"node-dev": "^8.0.0",
"type-fest": "^4.14.0",
"typescript": "^5.3.2"
},
"dependencies": {
Expand Down
37 changes: 20 additions & 17 deletions packages/component/src/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,44 @@
import createEmotion from '@emotion/css/create-instance';
import { Composer as APIComposer, hooks, WebSpeechPonyfillFactory } from 'botframework-webchat-api';
import { Composer as SayComposer } from 'react-say';
import { singleToArray } from 'botframework-webchat-core';
import classNames from 'classnames';
import createEmotion from '@emotion/css/create-instance';
import createStyleSet from './Styles/createStyleSet';
import MarkdownIt from 'markdown-it';
import PropTypes from 'prop-types';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { Composer as SayComposer } from 'react-say';
import createStyleSet from './Styles/createStyleSet';

import createDefaultAttachmentMiddleware from './Attachment/createMiddleware';
import Dictation from './Dictation';
import ErrorBox from './ErrorBox';
import {
speechSynthesis as bypassSpeechSynthesis,
SpeechSynthesisUtterance as BypassSpeechSynthesisUtterance
} from './hooks/internal/BypassSpeechSynthesisPonyfill';
import ActivityTreeComposer from './providers/ActivityTree/ActivityTreeComposer';
import addTargetBlankToHyperlinksMarkdown from './Utils/addTargetBlankToHyperlinksMarkdown';
import createCSSKey from './Utils/createCSSKey';
import UITracker from './hooks/internal/UITracker';
import WebChatUIContext from './hooks/internal/WebChatUIContext';
import useStyleSet from './hooks/useStyleSet';
import createDefaultActivityMiddleware from './Middleware/Activity/createCoreMiddleware';
import createDefaultActivityStatusMiddleware from './Middleware/ActivityStatus/createCoreMiddleware';
import createDefaultAttachmentForScreenReaderMiddleware from './Middleware/AttachmentForScreenReader/createCoreMiddleware';
import createDefaultAttachmentMiddleware from './Attachment/createMiddleware';
import createDefaultAvatarMiddleware from './Middleware/Avatar/createCoreMiddleware';
import createDefaultCardActionMiddleware from './Middleware/CardAction/createCoreMiddleware';
import createDefaultScrollToEndButtonMiddleware from './Middleware/ScrollToEndButton/createScrollToEndButtonMiddleware';
import createDefaultToastMiddleware from './Middleware/Toast/createCoreMiddleware';
import createDefaultTypingIndicatorMiddleware from './Middleware/TypingIndicator/createCoreMiddleware';
import Dictation from './Dictation';
import ActivityTreeComposer from './providers/ActivityTree/ActivityTreeComposer';
import SendBoxComposer from './providers/internal/SendBox/SendBoxComposer';
import ModalDialogComposer from './providers/ModalDialog/ModalDialogComposer';
import addTargetBlankToHyperlinksMarkdown from './Utils/addTargetBlankToHyperlinksMarkdown';
import createCSSKey from './Utils/createCSSKey';
import downscaleImageToDataURL from './Utils/downscaleImageToDataURL';
import ErrorBox from './ErrorBox';
import mapMap from './Utils/mapMap';
import ModalDialogComposer from './providers/ModalDialog/ModalDialogComposer';
import SendBoxComposer from './providers/internal/SendBox/SendBoxComposer';
import UITracker from './hooks/internal/UITracker';
import useStyleSet from './hooks/useStyleSet';
import WebChatUIContext from './hooks/internal/WebChatUIContext';

import type { ComposerProps as APIComposerProps } from 'botframework-webchat-api';
import type { FC, ReactNode } from 'react';
import type { ContextOf } from './types/ContextOf';
import { type FocusSendBoxInit } from './types/internal/FocusSendBoxInit';
import { type FocusTranscriptInit } from './types/internal/FocusTranscriptInit';

const { useGetActivityByKey, useReferenceGrammarID, useStyleOptions } = hooks;

Expand Down Expand Up @@ -97,8 +100,8 @@ const ComposerCore: FC<ComposerCoreProps> = ({
const [dictateAbortable, setDictateAbortable] = useState();
const [referenceGrammarID] = useReferenceGrammarID();
const [styleOptions] = useStyleOptions();
const focusSendBoxCallbacksRef = useRef([]);
const focusTranscriptCallbacksRef = useRef([]);
const focusSendBoxCallbacksRef = useRef<((init: FocusSendBoxInit) => Promise<void>)[]>([]);
const focusTranscriptCallbacksRef = useRef<((init: FocusTranscriptInit) => Promise<void>)[]>([]);
const internalMarkdownIt = useMemo(() => new MarkdownIt(), []);
const scrollToCallbacksRef = useRef([]);
const scrollToEndCallbacksRef = useRef([]);
Expand Down Expand Up @@ -202,7 +205,7 @@ const ComposerCore: FC<ComposerCoreProps> = ({
[transcriptFocusObserversRef, setNumTranscriptFocusObservers]
);

const context = useMemo(
const context = useMemo<ContextOf<typeof WebChatUIContext>>(
() => ({
dictateAbortable,
dispatchScrollPosition,
Expand Down
34 changes: 20 additions & 14 deletions packages/component/src/SendBox/SuggestedAction.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { hooks } from 'botframework-webchat-api';
import type { DirectLineCardAction } from 'botframework-webchat-core';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { MouseEventHandler, useCallback, VFC } from 'react';
import type { DirectLineCardAction } from 'botframework-webchat-core';

import AccessibleButton from '../Utils/AccessibleButton';
import connectToWebChat from '../connectToWebChat';
import useFocus from '../hooks/useFocus';
import useFocusAccessKeyEffect from '../Utils/AccessKeySink/useFocusAccessKeyEffect';
import useFocusVisible from '../hooks/internal/useFocusVisible';
import useItemRef from '../providers/RovingTabIndex/useItemRef';
import useLocalizeAccessKey from '../hooks/internal/useLocalizeAccessKey';
import useScrollToEnd from '../hooks/useScrollToEnd';
import useStyleSet from '../hooks/useStyleSet';
import useStyleToEmotionObject from '../hooks/internal/useStyleToEmotionObject';
import useSuggestedActionsAccessKey from '../hooks/internal/useSuggestedActionsAccessKey';
import useFocus from '../hooks/useFocus';
import useScrollToEnd from '../hooks/useScrollToEnd';
import useStyleSet from '../hooks/useStyleSet';
import useItemRef from '../providers/RovingTabIndex/useItemRef';
import AccessibleButton from '../Utils/AccessibleButton';
import useFocusAccessKeyEffect from '../Utils/AccessKeySink/useFocusAccessKeyEffect';

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

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

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

// TODO: [P3] #XXX We should not destruct DirectLineCardAction into React props and pass them in. It makes typings difficult.
// Instead, we should pass a "cardAction" props.
performCardAction({ displayText, text, type, value } as DirectLineCardAction, { target });

// Since "openUrl" action do not submit, the suggested action buttons do not hide after click.
type === 'openUrl' && setSuggestedActions([]);
// Since "openUrl" action do not submit, the suggested action buttons do not hide after click.
type === 'openUrl' && setSuggestedActions([]);

focus('sendBoxWithoutKeyboard');
scrollToEnd();
scrollToEnd();
})();
},
[displayText, focus, performCardAction, scrollToEnd, setSuggestedActions, text, type, value]
);
Expand Down
35 changes: 19 additions & 16 deletions packages/component/src/SendBox/TextBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { useCallback, useMemo, useRef } from 'react';

import { ie11 } from '../Utils/detectBrowser';
import AccessibleInputText from '../Utils/AccessibleInputText';
import AutoResizeTextArea from './AutoResizeTextArea';
import navigableEvent from '../Utils/TypeFocusSink/navigableEvent';
import { ie11 } from '../Utils/detectBrowser';
import useRegisterFocusSendBox from '../hooks/internal/useRegisterFocusSendBox';
import useStyleToEmotionObject from '../hooks/internal/useStyleToEmotionObject';
import useScrollDown from '../hooks/useScrollDown';
import useScrollUp from '../hooks/useScrollUp';
import useStyleSet from '../hooks/useStyleSet';
import useStyleToEmotionObject from '../hooks/internal/useStyleToEmotionObject';
import useSubmit from '../providers/internal/SendBox/useSubmit';
import withEmoji from '../withEmoji/withEmoji';
import AutoResizeTextArea from './AutoResizeTextArea';

import type { MutableRefObject } from 'react';

Expand Down Expand Up @@ -163,8 +163,9 @@ const TextBox = ({ className }) => {
[scrollDown, scrollUp]
);

const focusCallback = useCallback<(options?: { noKeyboard?: boolean }) => void>(
({ noKeyboard } = {}) => {
const focusCallback = useCallback<Parameters<typeof useRegisterFocusSendBox>[0]>(
options => {
const { noKeyboard } = options;
const { current } = inputElementRef;

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

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

// TODO: [P2] We should update this logic to handle quickly-successive `focusCallback`.
// If a succeeding `focusCallback` is being called, the `setTimeout` should run immediately.
// Or the second `focusCallback` should not set `readonly` to `true`.
setTimeout(() => {
const { current } = inputElementRef;

if (current) {
current.focus();
readOnly ? current.setAttribute('readonly', readOnly) : current.removeAttribute('readonly');
}
}, 0);
options.waitUntil(
(async function () {
// TODO: [P2] We should update this logic to handle quickly-successive `focusCallback`.
// If a succeeding `focusCallback` is being called, the `setTimeout` should run immediately.
// Or the second `focusCallback` should not set `readonly` to `true`.
await new Promise(resolve => setTimeout(resolve, 0));

if (current) {
current.focus();
readOnly ? current.setAttribute('readonly', readOnly) : current.removeAttribute('readonly');
}
})()
);
} else {
current.focus();
}
Expand Down
5 changes: 0 additions & 5 deletions packages/component/src/hooks/internal/WebChatUIContext.js

This file was deleted.

13 changes: 13 additions & 0 deletions packages/component/src/hooks/internal/WebChatUIContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createContext, type MutableRefObject } from 'react';

import { type FocusSendBoxInit } from '../../types/internal/FocusSendBoxInit';
import { type FocusTranscriptInit } from '../../types/internal/FocusTranscriptInit';

export type ContextType = {
focusSendBoxCallbacksRef: MutableRefObject<((init: FocusSendBoxInit) => Promise<void>)[]>;
focusTranscriptCallbacksRef: MutableRefObject<((init: FocusTranscriptInit) => Promise<void>)[]>;
};

const context = createContext<ContextType>(undefined as ContextType);

export default context;
Loading

0 comments on commit 82ad15e

Please sign in to comment.