diff --git a/CHANGELOG.md b/CHANGELOG.md index 7baf566d9b..cc7aa02d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Added an information message to the telephone keypad, in PR [#5140](https://github.com/microsoft/BotFramework-WebChat/pull/5140) - Added animation to focus indicator and pixel-perfected, in PR [#5143](https://github.com/microsoft/BotFramework-WebChat/pull/5143) - Integrated focus management for send box, in PR [#5150](https://github.com/microsoft/BotFramework-WebChat/pull/5150), by [@OEvgeny](https://github.com/OEvgeny) + - Added keyboard navigation support into suggested actions, in PR [#5154](https://github.com/microsoft/BotFramework-WebChat/pull/5154), by [@OEvgeny](https://github.com/OEvgeny) - (Experimental) Added `` component which can be used to localize strings, by [@OEvgeny](https://github.com/OEvgeny) in PR [#5140](https://github.com/microsoft/BotFramework-WebChat/pull/5140) - Added `` component to apply theme pack to Web Chat, by [@compulim](https://github.com/compulim), in PR [#5120](https://github.com/microsoft/BotFramework-WebChat/pull/5120) - Added `useMakeThumbnail` hook option to create a thumbnail from the file given, by [@compulim](https://github.com/compulim), in PR [#5123](https://github.com/microsoft/BotFramework-WebChat/pull/5123) and [#5122](https://github.com/microsoft/BotFramework-WebChat/pull/5122) diff --git a/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-1-snap.png b/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-1-snap.png new file mode 100644 index 0000000000..900a437633 Binary files /dev/null and b/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-2-snap.png b/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-2-snap.png new file mode 100644 index 0000000000..45a88c3983 Binary files /dev/null and b/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-3-snap.png b/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-3-snap.png new file mode 100644 index 0000000000..cb0f21f958 Binary files /dev/null and b/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-3-snap.png differ diff --git a/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-4-snap.png b/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-4-snap.png new file mode 100644 index 0000000000..9e8e7b6890 Binary files /dev/null and b/__tests__/__image_snapshots__/html/suggested-actions-focus-js-fluent-theme-applied-suggested-actions-roving-focus-4-snap.png differ diff --git a/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-1-snap.png b/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-1-snap.png new file mode 100644 index 0000000000..66e2844a97 Binary files /dev/null and b/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-2-snap.png b/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-2-snap.png new file mode 100644 index 0000000000..f94d4100cc Binary files /dev/null and b/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-3-snap.png b/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-3-snap.png new file mode 100644 index 0000000000..66e2844a97 Binary files /dev/null and b/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-3-snap.png differ diff --git a/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-4-snap.png b/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-4-snap.png new file mode 100644 index 0000000000..7911fe3799 Binary files /dev/null and b/__tests__/__image_snapshots__/html/suggested-actions-layout-flow-focus-js-fluent-theme-applied-suggested-actions-roving-focus-in-flow-layout-4-snap.png differ diff --git a/__tests__/html/fluentTheme/suggestedActions.focus.html b/__tests__/html/fluentTheme/suggestedActions.focus.html new file mode 100644 index 0000000000..e29bf8c398 --- /dev/null +++ b/__tests__/html/fluentTheme/suggestedActions.focus.html @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/__tests__/html/fluentTheme/suggestedActions.focus.js b/__tests__/html/fluentTheme/suggestedActions.focus.js new file mode 100644 index 0000000000..d9fa21d946 --- /dev/null +++ b/__tests__/html/fluentTheme/suggestedActions.focus.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('Fluent theme applied', () => { + test('suggested actions roving focus', () => runHTML('fluentTheme/suggestedActions.focus')); +}); diff --git a/__tests__/html/fluentTheme/suggestedActions.layout.flow.focus.html b/__tests__/html/fluentTheme/suggestedActions.layout.flow.focus.html new file mode 100644 index 0000000000..046635db31 --- /dev/null +++ b/__tests__/html/fluentTheme/suggestedActions.layout.flow.focus.html @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/__tests__/html/fluentTheme/suggestedActions.layout.flow.focus.js b/__tests__/html/fluentTheme/suggestedActions.layout.flow.focus.js new file mode 100644 index 0000000000..4a760f9490 --- /dev/null +++ b/__tests__/html/fluentTheme/suggestedActions.layout.flow.focus.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('Fluent theme applied', () => { + test('suggested actions roving focus in flow layout', () => runHTML('fluentTheme/suggestedActions.layout.flow.focus')); +}); diff --git a/packages/fluent-theme/src/components/suggestedActions/SuggestedAction.tsx b/packages/fluent-theme/src/components/suggestedActions/SuggestedAction.tsx index 19626cb432..bf2fa5f917 100644 --- a/packages/fluent-theme/src/components/suggestedActions/SuggestedAction.tsx +++ b/packages/fluent-theme/src/components/suggestedActions/SuggestedAction.tsx @@ -1,10 +1,11 @@ import { hooks } from 'botframework-webchat-component'; import { type DirectLineCardAction } from 'botframework-webchat-core'; import cx from 'classnames'; -import React, { MouseEventHandler, memo, useCallback, useRef } from 'react'; +import React, { MouseEventHandler, memo, useCallback } from 'react'; import styles from './SuggestedAction.module.css'; import { useStyles } from '../../styles'; import AccessibleButton from './AccessibleButton'; +import { useRovingFocusItemRef } from './private/rovingFocus'; const { useDisabled, useFocus, usePerformCardAction, useScrollToEnd, useStyleSet, useSuggestedActions } = hooks; @@ -36,6 +37,7 @@ function SuggestedAction({ displayText, image, imageAlt, + itemIndex, text, type, value @@ -44,7 +46,7 @@ function SuggestedAction({ const [{ suggestedAction: suggestedActionStyleSet }] = useStyleSet(); const [disabled] = useDisabled(); const focus = useFocus(); - const focusRef = useRef(null); + const focusRef = useRovingFocusItemRef(itemIndex); const performCardAction = usePerformCardAction(); const classNames = useStyles(styles); const scrollToEnd = useScrollToEnd(); diff --git a/packages/fluent-theme/src/components/suggestedActions/SuggestedActions.tsx b/packages/fluent-theme/src/components/suggestedActions/SuggestedActions.tsx index f6ad4c9d93..8d79e196cb 100644 --- a/packages/fluent-theme/src/components/suggestedActions/SuggestedActions.tsx +++ b/packages/fluent-theme/src/components/suggestedActions/SuggestedActions.tsx @@ -1,12 +1,13 @@ import { hooks } from 'botframework-webchat-component'; import cx from 'classnames'; -import React, { memo, type ReactNode } from 'react'; +import React, { memo, useCallback, type ReactNode } from 'react'; import SuggestedAction from './SuggestedAction'; import computeSuggestedActionText from './private/computeSuggestedActionText'; import styles from './SuggestedActions.module.css'; import { useStyles } from '../../styles'; +import RovingFocusProvider from './private/rovingFocus'; -const { useLocalizer, useStyleOptions, useStyleSet, useSuggestedActions } = hooks; +const { useFocus, useLocalizer, useStyleOptions, useStyleSet, useSuggestedActions } = hooks; function SuggestedActionStackedOrFlowContainer( props: Readonly<{ @@ -44,6 +45,12 @@ function SuggestedActions() { const classNames = useStyles(styles); const localize = useLocalizer(); const [suggestedActions] = useSuggestedActions(); + const focus = useFocus(); + + const handleEscapeKey = useCallback(() => { + focus('sendBox'); + }, [focus]); + const children = suggestedActions.map((cardAction, index) => { const { displayText, image, imageAltText, text, type, value } = cardAction as { displayText?: string; @@ -85,13 +92,16 @@ function SuggestedActions() { /> ); }); + return ( - - {children} - + + + {children} + + ); } diff --git a/packages/fluent-theme/src/components/suggestedActions/private/rovingFocus.tsx b/packages/fluent-theme/src/components/suggestedActions/private/rovingFocus.tsx new file mode 100644 index 0000000000..b604a9a452 --- /dev/null +++ b/packages/fluent-theme/src/components/suggestedActions/private/rovingFocus.tsx @@ -0,0 +1,180 @@ +/* eslint-disable no-magic-numbers */ +import React, { + type MutableRefObject, + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef +} from 'react'; + +type ItemRef = MutableRefObject; + +type RovingFocusContextType = { + itemEffector: (ref: MutableRefObject, index: number) => () => void; +}; + +const RovingFocusContext = createContext({ + itemEffector: () => { + // This will be implemented when using in . + throw new Error('botframework-webchat-fluent-theme rovingFocus: no provider for RovingFocusContext.'); + } +}); + +function RovingFocusProvider( + props: Readonly<{ + children?: React.ReactNode | undefined; + direction?: 'vertical' | 'horizontal' | undefined; + onEscapeKey?: () => void; + }> +) { + const activeItemIndexRef = useRef(0); + const itemRefsRef = useRef([]); + + const updateItemTabIndex = useCallback( + ({ current }: ItemRef, index: number) => + current && (current.tabIndex = activeItemIndexRef.current === index ? 0 : -1), + [activeItemIndexRef] + ); + + const setActiveItemIndex = useCallback( + (valueOrFunction: number | ((value: number) => number)) => { + // All calls to this function is expected to be under event handlers (post-rendering). + let nextActiveItemIndex; + + if (typeof valueOrFunction === 'number') { + nextActiveItemIndex = valueOrFunction; + } else { + nextActiveItemIndex = valueOrFunction(activeItemIndexRef.current); + } + + // If the index points to no item, fallback to the first item. + // This makes sure at least one of the item in the container is selected. + if (nextActiveItemIndex && !itemRefsRef.current.at(nextActiveItemIndex)?.current) { + nextActiveItemIndex = 0; + } + + if (activeItemIndexRef.current !== nextActiveItemIndex) { + activeItemIndexRef.current = nextActiveItemIndex; + + itemRefsRef.current.forEach((ref, index) => updateItemTabIndex(ref, index)); + itemRefsRef.current.at(nextActiveItemIndex)?.current?.focus(); + } + }, + [updateItemTabIndex, itemRefsRef, activeItemIndexRef] + ); + + const handleFocus = useCallback( + event => { + const { target } = event; + + const index = itemRefsRef.current.findIndex(({ current }) => current === target); + + // prevent focusing the last element, if we didn't found the element focused + if (index !== -1) { + setActiveItemIndex(index); + } + }, + [itemRefsRef, setActiveItemIndex] + ); + + const handleSetNextActive = useCallback( + (key: string) => + (currentIndex: number): number => { + const isUnidirectional = !props.direction; + const isVerticalMove = /up|down/iu.test(key) && props.direction === 'vertical'; + const isHorizontalMove = /left|right/iu.test(key) && props.direction === 'horizontal'; + const isForwardMove = /right|down/iu.test(key); + const direction = isUnidirectional || isVerticalMove || isHorizontalMove ? (isForwardMove ? 1 : -1) : 0; + // The `itemRefsRef` array could be a sparse array. + // Thus, the next item may not be immediately next to the current one. + const itemIndices = itemRefsRef.current.map((_, index) => index); + const nextIndex = itemIndices.indexOf(currentIndex) + direction; + + return itemIndices.at(nextIndex) ?? 0; + }, + [props.direction] + ); + + const handleKeyDown = useCallback<(event: KeyboardEvent) => void>( + event => { + const { key } = event; + + switch (key) { + case 'Up': + case 'ArrowUp': + case 'Left': + case 'ArrowLeft': + case 'Down': + case 'ArrowDown': + case 'Right': + case 'ArrowRight': + setActiveItemIndex(handleSetNextActive(key)); + break; + + case 'Home': + setActiveItemIndex(0); + break; + + case 'End': + setActiveItemIndex(-1); + break; + + case 'Escape': + props.onEscapeKey?.(); + break; + + default: + return; + } + + event.preventDefault(); + event.stopPropagation(); + }, + [setActiveItemIndex, handleSetNextActive, props] + ); + + const itemEffector = useCallback( + (ref, index) => { + const { current } = ref; + + itemRefsRef.current[Number(index)] = ref; + + current.addEventListener('focus', handleFocus); + current.addEventListener('keydown', handleKeyDown); + + updateItemTabIndex(ref, index); + + return () => { + current.removeEventListener('focus', handleFocus); + current.removeEventListener('keydown', handleKeyDown); + + delete itemRefsRef.current[Number(index)]; + }; + }, + [handleFocus, handleKeyDown, updateItemTabIndex, itemRefsRef] + ); + + const value = useMemo( + () => ({ + itemEffector + }), + [itemEffector] + ); + + return {props.children}; +} + +export function useRovingFocusItemRef(itemIndex: number): MutableRefObject { + const ref = useRef(null); + + const { itemEffector } = useContext(RovingFocusContext); + + useEffect(() => itemEffector(ref, itemIndex)); + + return ref; +} + +export default memo(RovingFocusProvider);