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