diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd2c880fc..1f656ee89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Fixed + +- Fixes [#4020](https://github.com/microsoft/BotFramework-WebChat/issues/4020). With or without scan mode turned on, screen reader users should be able to press ENTER to focus on interactive activity, by [@compulim](https://github.com/compulim), in PR [#4041](https://github.com/microsoft/BotFramework-WebChat/pull/4041) +- Fixes [#4021](https://github.com/microsoft/BotFramework-WebChat/issues/4021). For screen reader usability, suggested actions container should not render "Is empty" alt text initially, by [@compulim](https://github.com/compulim), in PR [#4041](https://github.com/microsoft/BotFramework-WebChat/pull/4041) + ## [4.14.1] - 2021-09-07 ### Fixed diff --git a/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-adaptive-card-speak-property-js-accessibility-requirement-attachments-in-live-region-should-narrate-speak-property-adaptive-card-1-snap.png b/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-adaptive-card-speak-property-js-accessibility-requirement-attachments-in-live-region-should-narrate-speak-property-adaptive-card-1-snap.png index f749788252..298b9fe9aa 100644 Binary files a/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-adaptive-card-speak-property-js-accessibility-requirement-attachments-in-live-region-should-narrate-speak-property-adaptive-card-1-snap.png and b/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-adaptive-card-speak-property-js-accessibility-requirement-attachments-in-live-region-should-narrate-speak-property-adaptive-card-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-file-js-accessibility-requirement-attachments-in-live-region-file-1-snap.png b/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-file-js-accessibility-requirement-attachments-in-live-region-file-1-snap.png index 18abe042b0..7b51304d81 100644 Binary files a/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-file-js-accessibility-requirement-attachments-in-live-region-file-1-snap.png and b/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-file-js-accessibility-requirement-attachments-in-live-region-file-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-hero-card-js-accessibility-requirement-attachments-in-live-region-hero-card-1-snap.png b/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-hero-card-js-accessibility-requirement-attachments-in-live-region-hero-card-1-snap.png index 392f859315..2543c7d8a8 100644 Binary files a/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-hero-card-js-accessibility-requirement-attachments-in-live-region-hero-card-1-snap.png and b/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-hero-card-js-accessibility-requirement-attachments-in-live-region-hero-card-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-sign-in-card-js-accessibility-requirement-attachments-in-live-region-sign-in-card-1-snap.png b/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-sign-in-card-js-accessibility-requirement-attachments-in-live-region-sign-in-card-1-snap.png index db191f649f..028f8d132e 100644 Binary files a/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-sign-in-card-js-accessibility-requirement-attachments-in-live-region-sign-in-card-1-snap.png and b/__tests__/__image_snapshots__/html/accessibility-live-region-attachment-sign-in-card-js-accessibility-requirement-attachments-in-live-region-sign-in-card-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/transcript-navigation-focus-attachment-enter-key-js-transcript-navigation-should-focus-inside-the-attachment-when-enter-key-is-pressed-1-snap.png b/__tests__/__image_snapshots__/html/transcript-navigation-focus-attachment-enter-key-js-transcript-navigation-should-focus-inside-the-attachment-when-enter-key-is-pressed-1-snap.png new file mode 100644 index 0000000000..4c5bc5478a Binary files /dev/null and b/__tests__/__image_snapshots__/html/transcript-navigation-focus-attachment-enter-key-js-transcript-navigation-should-focus-inside-the-attachment-when-enter-key-is-pressed-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/transcript-navigation-focus-attachment-screen-reader-primary-action-nvda-js-transcript-navigation-with-nvda-in-browse-mode-should-focus-inside-the-attachment-when-enter-is-pressed-1-snap.png b/__tests__/__image_snapshots__/html/transcript-navigation-focus-attachment-screen-reader-primary-action-nvda-js-transcript-navigation-with-nvda-in-browse-mode-should-focus-inside-the-attachment-when-enter-is-pressed-1-snap.png new file mode 100644 index 0000000000..4c5bc5478a Binary files /dev/null and b/__tests__/__image_snapshots__/html/transcript-navigation-focus-attachment-screen-reader-primary-action-nvda-js-transcript-navigation-with-nvda-in-browse-mode-should-focus-inside-the-attachment-when-enter-is-pressed-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/transcript-navigation-focus-attachment-screen-reader-primary-action-windows-narrator-js-transcript-navigation-with-windows-narrator-should-focus-inside-the-attachment-when-do-primary-action-is-performed-1-snap.png b/__tests__/__image_snapshots__/html/transcript-navigation-focus-attachment-screen-reader-primary-action-windows-narrator-js-transcript-navigation-with-windows-narrator-should-focus-inside-the-attachment-when-do-primary-action-is-performed-1-snap.png new file mode 100644 index 0000000000..4c5bc5478a Binary files /dev/null and b/__tests__/__image_snapshots__/html/transcript-navigation-focus-attachment-screen-reader-primary-action-windows-narrator-js-transcript-navigation-with-windows-narrator-should-focus-inside-the-attachment-when-do-primary-action-is-performed-1-snap.png differ diff --git a/__tests__/html/accessibility.usability.suggestedActions.hideOnInitial.html b/__tests__/html/accessibility.usability.suggestedActions.hideOnInitial.html new file mode 100644 index 0000000000..50e877b983 --- /dev/null +++ b/__tests__/html/accessibility.usability.suggestedActions.hideOnInitial.html @@ -0,0 +1,101 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/accessibility.usability.suggestedActions.hideOnInitial.js b/__tests__/html/accessibility.usability.suggestedActions.hideOnInitial.js new file mode 100644 index 0000000000..f1e12ee10c --- /dev/null +++ b/__tests__/html/accessibility.usability.suggestedActions.hideOnInitial.js @@ -0,0 +1,6 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('accessibility usability', () => { + test('should not render suggested actions container at initial', () => + runHTML('accessibility.usability.suggestedActions.hideOnInitial.html')); +}); diff --git a/__tests__/html/transcript.navigation.focusAttachment.enterKey.html b/__tests__/html/transcript.navigation.focusAttachment.enterKey.html new file mode 100644 index 0000000000..36f0433e2d --- /dev/null +++ b/__tests__/html/transcript.navigation.focusAttachment.enterKey.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html/transcript.navigation.focusAttachment.enterKey.js b/__tests__/html/transcript.navigation.focusAttachment.enterKey.js new file mode 100644 index 0000000000..5df3319c57 --- /dev/null +++ b/__tests__/html/transcript.navigation.focusAttachment.enterKey.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('transcript navigation', () => { + test('should focus inside the attachment when ENTER key is pressed', () => runHTML('transcript.navigation.focusAttachment.enterKey')); +}); diff --git a/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.nvda.html b/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.nvda.html new file mode 100644 index 0000000000..8550f551a6 --- /dev/null +++ b/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.nvda.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.nvda.js b/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.nvda.js new file mode 100644 index 0000000000..f06d7c1836 --- /dev/null +++ b/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.nvda.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('transcript navigation with NVDA in browse mode', () => { + test('should focus inside the attachment when ENTER is pressed', () => runHTML('transcript.navigation.focusAttachment.screenReaderPrimaryAction.nvda')); +}); diff --git a/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.windowsNarrator.html b/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.windowsNarrator.html new file mode 100644 index 0000000000..9c022f594d --- /dev/null +++ b/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.windowsNarrator.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + +
+ + + diff --git a/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.windowsNarrator.js b/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.windowsNarrator.js new file mode 100644 index 0000000000..05c706e854 --- /dev/null +++ b/__tests__/html/transcript.navigation.focusAttachment.screenReaderPrimaryAction.windowsNarrator.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('transcript navigation with Windows Narrator', () => { + test('should focus inside the attachment when "do primary action" is performed', () => runHTML('transcript.navigation.focusAttachment.screenReaderPrimaryAction.windowsNarrator')); +}); diff --git a/packages/api/package.json b/packages/api/package.json index 1f91c9d975..07b4f23325 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -30,7 +30,7 @@ "prestart": "npm run build:babel", "start": "concurrently --kill-others --names \"babel,globalize,tsc\" \"npm run start:babel\" \"npm run start:globalize\" \"npm run start:typescript\"", "start:babel": "npm run build:babel -- --skip-initial-build --watch", - "start:globalize": "node-dev --respawn scripts/createPrecompiledGlobalize.js", + "start:globalize": "node-dev --respawn scripts/createPrecompiledGlobalize.mjs", "start:typescript": "npm run build:typescript -- --watch" }, "devDependencies": { diff --git a/packages/api/src/localization/en-US.json b/packages/api/src/localization/en-US.json index eddead2d17..a6065be981 100644 --- a/packages/api/src/localization/en-US.json +++ b/packages/api/src/localization/en-US.json @@ -11,7 +11,7 @@ "ACTIVITY_BOT_ATTACHED_ALT": "Bot attached:", "_ACTIVITY_BOT_ATTACHED_ALT.comment": "This is for screen reader and is narrated before each attachments sent by the bot.", "ACTIVITY_ERROR_BOX_TITLE": "Error message", - "ACTIVITY_INTERACTIVE_LABEL_ALT": "Press ENTER to interact.", + "ACTIVITY_INTERACTIVE_LABEL_ALT": "Click to interact.", "_ACTIVITY_INTERACTIVE_LABEL_ALT.comment": "This is for screen reader. When the user is navigating on the transcript, it give hints if the current activity have interactive contents.", "ACTIVITY_YOU_ATTACHED_ALT": "You attached:", "_ACTIVITY_YOU_ATTACHED_ALT.comment": "This is for screen reader and is narrated before each attachments sent by the user.", diff --git a/packages/api/src/localization/yue.json b/packages/api/src/localization/yue.json index cff49133b8..ff9944a3d1 100644 --- a/packages/api/src/localization/yue.json +++ b/packages/api/src/localization/yue.json @@ -5,7 +5,7 @@ "ACTIVITY_BOT_ATTACHED_ALT": "Bot 嘅附件:", "ACTIVITY_BOT_SAID_ALT": "Bot $1 話:", "ACTIVITY_ERROR_BOX_TITLE": "錯嘅信息", - "ACTIVITY_INTERACTIVE_LABEL_ALT": "襟 ENTER 嚟進行互動。", + "ACTIVITY_INTERACTIVE_LABEL_ALT": "襟呢度嚟進行互動。", "ACTIVITY_NUM_ATTACHMENTS_FEW_ALT": "$1 件附件。", "ACTIVITY_NUM_ATTACHMENTS_MANY_ALT": "$1 件附件。", "ACTIVITY_NUM_ATTACHMENTS_ONE_ALT": "一件附件。", diff --git a/packages/component/src/BasicTranscript.js b/packages/component/src/BasicTranscript.js index dc1f3a72dd..8e7da44dac 100644 --- a/packages/component/src/BasicTranscript.js +++ b/packages/component/src/BasicTranscript.js @@ -298,6 +298,16 @@ const InternalTranscript = ({ activityElementsRef, className }) => { rootElement && rootElement.focus(); }; + // Focus on the first tabbable element inside the activity. + // This will be triggered by pressing ENTER while focusing on an interactive activity. + const focusInside = () => { + const [firstTabbableElement] = tabbableElements( + activityElementsRef.current.find(activityElement => activityElement.key === key)?.element + ).filter(({ className }) => className !== 'webchat__basic-transcript__activity-sentinel'); + + firstTabbableElement?.focus(); + }; + renderingElements.push({ activity, @@ -313,6 +323,52 @@ const InternalTranscript = ({ activityElementsRef, className }) => { // Calling this function will put the focus on the transcript and the activity. focusActivity, + // Calling this function will focus on the first tabbable element in the activity. + focusInside, + + handleClick: event => { + // (Related to #4020) + // + // This is called while screen reader is running: + // + // 1. When scan mode is on (Windows Narrator) or in browse mode (NVDA), ENTER key is pressed, or; + // 2. When scan mode is off (Windows Narrator) or in focus mode (NVDA), CAPSLOCK + ENTER is pressed + // + // Although `document.activeElement` (a.k.a. primary focus) is on the transcript, + // when ENTER key is pressed with screen reader in scan mode, screen reader will + // "do primary action", which ask the browser to send a `click` event to the + // active descendant (a.k.a. focused activity). + // + // While outside of scan mode, this will also capture CAPSLOCK + ENTER, + // which is a key combo for "do primary action" or "activates the current navigator object". + // + // We cannot capture plain ENTER key outside of scan mode here. + // We can only capture it on `keydown` event fired to the transcript element. + // + // Also see https://github.com/nvaccess/nvda/issues/7898. + + const { currentTarget, target } = event; + + // The followings are for Windows Narrator: + // - When scan mode is on + // - Press ENTER will dispatch "click" event to the
  • element + // - This is called "Do primary action" + if (target === currentTarget) { + return focusInside(); + } + + // The followings are for NVDA: + // - When in browse mode (red border), and the red box is around the + // - The much simplified DOM tree:
  • ...

  • + // - Press ENTER will dispatch `click` event + // - NVDA 2020.2 (buggy): In additional to ENTER, when navigating using UP/DOWN arrow keys, it dispatch "click" event to the
    element + // - NVDA 2021.2: After press ENTER, it dispatch 2 `click` events. First to the
    element, then to the element currently bordered in red (e.g.

    ) + // - Perhaps, we should add role="application" to container of Web Chat to disable browse mode, as we are not a web document and already offered a full-fledge navigation experience + if (document.getElementById(currentTarget.getAttribute('aria-labelledby')).contains(target)) { + return focusInside(); + } + }, + // When a child of the activity receives focus, notify the transcript to set the aria-activedescendant to this activity. handleFocus: () => { setFocusedActivityKey(getActivityUniqueId(activity)); @@ -627,22 +683,10 @@ const InternalTranscript = ({ activityElementsRef, className }) => { break; case 'Enter': - if (!fromEndOfTranscriptIndicator) { - const focusedActivityEntry = renderingElements.find(({ key }) => key === focusedActivityKey); - - if (focusedActivityEntry) { - const { element: focusedActivityElement } = - activityElementsRef.current.find(({ activity }) => activity === focusedActivityEntry.activity) || {}; - - if (focusedActivityElement) { - const [firstTabbableElement] = tabbableElements(focusedActivityElement).filter( - ({ className }) => className !== 'webchat__basic-transcript__activity-sentinel' - ); - - firstTabbableElement && firstTabbableElement.focus(); - } - } - } + // This is capturing plain ENTER. + // When screen reader is not running, or screen reader is running outside of scan mode, the ENTER key will be captured here. + fromEndOfTranscriptIndicator || + renderingElements.find(({ key }) => key === focusedActivityKey)?.focusInside(); break; @@ -666,7 +710,7 @@ const InternalTranscript = ({ activityElementsRef, className }) => { event.stopPropagation(); } }, - [focusedActivityKey, activityElementsRef, focusRelativeActivity, focus, terminatorRef, renderingElements] + [focusedActivityKey, focusRelativeActivity, focus, renderingElements, terminatorRef] ); const labelId = useUniqueId('webchat__basic-transcript__label'); @@ -793,6 +837,7 @@ const InternalTranscript = ({ activityElementsRef, className }) => { activity, callbackRef, focusActivity, + handleClick, handleFocus, handleKeyDown, handleMouseDownCapture, @@ -810,7 +855,7 @@ const InternalTranscript = ({ activityElementsRef, className }) => { ) => { const { ariaLabelID, element } = activityElementsRef.current.find(entry => entry.activity === activity) || {}; - const activeDescendant = focusedActivityKey === key; + const isActiveDescendant = focusedActivityKey === key; const isContentInteractive = !!(element ? tabbableElements(element.querySelector('.webchat__basic-transcript__activity-box')).length : 0); @@ -823,12 +868,18 @@ const InternalTranscript = ({ activityElementsRef, className }) => { 'webchat__basic-transcript__activity--from-bot': role !== 'user', 'webchat__basic-transcript__activity--from-user': role === 'user' })} - // Set "id" for valid for accessibility. + // Set "id" is required for accessibility active descendant feature. /* eslint-disable-next-line react/forbid-dom-props */ - id={activeDescendant ? activeDescendantElementId : undefined} + id={isActiveDescendant ? activeDescendantElementId : undefined} key={key} + // This is for capturing "do primary action" done by the screen reader. + // With screen reader, will narrate "Press ENTER to interact". But in scan mode, ENTER means "do primary action". + // If `onClick` is set, screen reader will send click event when "do primary action". + // Related to #4020. + onClick={handleClick} onFocus={handleFocus} onKeyDown={handleKeyDown} + // When NVDA is in browse mode, using up/down arrow key to "browse" will dispatch "click" and "mousedown" events for

    element (inside ). onMouseDownCapture={handleMouseDownCapture} ref={callbackRef} > @@ -859,7 +910,7 @@ const InternalTranscript = ({ activityElementsRef, className }) => {
    diff --git a/packages/component/src/SendBox/SuggestedAction.js b/packages/component/src/SendBox/SuggestedAction.js index 060ff498c8..ca04cb300d 100644 --- a/packages/component/src/SendBox/SuggestedAction.js +++ b/packages/component/src/SendBox/SuggestedAction.js @@ -48,8 +48,8 @@ const SuggestedAction = ({ buttonText, className, displayText, image, imageAlt, const focusRef = useRef(); const localizeAccessKey = useLocalizeAccessKey(); const performCardAction = usePerformCardAction(); - const scrollToEnd = useScrollToEnd(); const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + ''; + const scrollToEnd = useScrollToEnd(); const handleClick = useCallback( ({ target }) => { diff --git a/packages/component/src/SendBox/SuggestedActions.tsx b/packages/component/src/SendBox/SuggestedActions.tsx index 402b5f26f2..bdd5196fa4 100644 --- a/packages/component/src/SendBox/SuggestedActions.tsx +++ b/packages/component/src/SendBox/SuggestedActions.tsx @@ -5,7 +5,7 @@ import { hooks } from 'botframework-webchat-api'; import BasicFilm, { createBasicStyleSet as createBasicStyleSetForReactFilm } from 'react-film'; import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React, { FC, useMemo } from 'react'; +import React, { FC, useMemo, useRef } from 'react'; import connectToWebChat from '../connectToWebChat'; import ScreenReaderText from '../ScreenReaderText'; @@ -215,6 +215,7 @@ type SuggestedActionsProps = { const SuggestedActions: FC = ({ className, suggestedActions = [] }) => { const [{ suggestedActionLayout, suggestedActionsStackedLayoutButtonTextWrap }] = useStyleOptions(); const [accessKey] = useSuggestedActionsAccessKey(); + const hideEmptyRef = useRef(true); const localize = useLocalizer(); const localizeAccessKey = useLocalizeAccessKey(); @@ -258,6 +259,22 @@ const SuggestedActions: FC = ({ className, suggestedActio ); }); + // (Related to #4021) + // + // To improve accessibility UX, if there are no suggested actions, and this container was never shown. + // Then, avoid rendering the alt-text "Suggested Actions Container: Is empty". + // + // This is to reduce the narration of "Is empty". + // + // After any suggested actions were shown during the lifetime of this container, then we will + // continue to start showing "Suggested Actions Container: Is empty" when the container is empty. + if (!children.length && hideEmptyRef.current) { + return null; + } + + // Otherwise, if we have rendered once, we will continue to render "Is empty". + hideEmptyRef.current = false; + if (suggestedActionLayout === 'flow') { return ( diff --git a/packages/component/src/Utils/FocusRedirector.js b/packages/component/src/Utils/FocusRedirector.js index 4c7a1062cc..d2a1c55684 100644 --- a/packages/component/src/Utils/FocusRedirector.js +++ b/packages/component/src/Utils/FocusRedirector.js @@ -18,7 +18,11 @@ const FocusRedirector = ({ className, onFocus, redirectRef }) => { onFocus && onFocus(); }, [onFocus, redirectRef]); - return
    ; + // For NVDA, we should set aria-hidden="true". + // When using NVDA in browse mode, press up/down arrow keys will focus on this redirector. + // This redirector is designed to capture TAB only and should not react on browse mode. + // However, reacting with browse mode is currently okay. Just better to leave it alone. + return