Skip to content

Commit

Permalink
Fluent: suggested actions roving focus (#5154)
Browse files Browse the repository at this point in the history
* Fluent: suggested actions roving focus

* Changelog

* Update packages/fluent-theme/src/components/suggestedActions/private/rovingFocus.tsx

* fix nit
  • Loading branch information
OEvgeny committed May 1, 2024
1 parent 71714d3 commit c30e4e2
Show file tree
Hide file tree
Showing 16 changed files with 469 additions and 10 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<LocalizeString />` 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 `<ThemeProvider>` 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)
Expand Down
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
156 changes: 156 additions & 0 deletions __tests__/html/fluentTheme/suggestedActions.focus.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<!doctype html>
<html lang="en-US">

<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react@16.8.6/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.6/umd/react-dom.production.min.js"></script>
<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>
<script crossorigin="anonymous" src="/__dist__/botframework-webchat-fluent-theme.production.min.js"></script>
</head>

<body>
<main id="webchat"></main>
<script type="text/babel">
run(async function () {
const {
React,
ReactDOM: { render },
WebChat: { FluentThemeProvider, ReactWebChat }
} = window; // Imports in UMD fashion.

const { directLine, store } = testHelpers.createDirectLineEmulator();

const App = () => <ReactWebChat directLine={directLine} store={store} />;

render(
<FluentThemeProvider>
<App />
</FluentThemeProvider>,
document.getElementById('webchat')
);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({
type: 'message',
textFormat: 'plain',
text: 'Please select one of the actions below',
suggestedActions: {
actions: [
{
image: `https://raw.githubusercontent.com/compulim/BotFramework-MockBot/master/public/assets/square-icon.png`,
imageAltText: 'a blue square',
title: 'IM back as string',
type: 'imBack',
value: 'postback imback-string'
},
{
image: `https://raw.githubusercontent.com/compulim/BotFramework-MockBot/master/public/assets/square-icon-red.png`,
imageAltText: 'a red square',
title: 'Post back as string',
type: 'postBack',
value: 'postback postback-string'
},
{
image: `https://raw.githubusercontent.com/compulim/BotFramework-MockBot/master/public/assets/square-icon-green.png`,
imageAltText: 'a green square',
title: 'Post back as JSON',
text: 'Some text',
type: 'postBack',
value: {
hello: 'World!'
}
},
{
image: `https://raw.githubusercontent.com/compulim/BotFramework-MockBot/master/public/assets/square-icon-purple.png`,
imageAltText: 'a purple square',
displayText: 'say Hello World!',
title: 'Message back as JSON with display text',
text: 'Some text',
type: 'messageBack',
value: {
hello: 'World!'
}
},
{
image: `https://raw.githubusercontent.com/compulim/BotFramework-MockBot/master/public/assets/square-icon-purple.png`,
imageAltText: 'a purple square',
title: 'Message back as JSON without display text',
type: 'messageBack',
value: {
hello: 'World!'
}
},
{
displayText: 'Aloha',
image: `https://raw.githubusercontent.com/compulim/BotFramework-MockBot/master/public/assets/square-icon-purple.png`,
imageAltText: 'a purple square',
text: 'echo Hello',
title: 'Aloha',
type: 'messageBack'
}
],
to: []
}
});

document.querySelector(`[data-testid="${WebChat.testIds.sendBoxTextBox}"]`).focus();

// WHEN: Focus suggested actions
await host.sendShiftTab();

// THEN: Should focus first suggested action
await host.snapshot();
const firstAction = document.activeElement;

// WHEN: Press arrow right key four times:
await host.sendKeys('ARROW_RIGHT'); // 2nd
await host.sendKeys('ARROW_RIGHT'); // 3rd
await host.sendKeys('ARROW_RIGHT'); // 4th
await host.sendKeys('ARROW_RIGHT'); // 5th
await host.sendKeys('ARROW_RIGHT'); // 6th

// THEN: Should focus the last suggested action
expect(document.activeElement?.innerText).toContain('Aloha');
const lastAction = document.activeElement;
await host.snapshot();

// WHEN: escape key is pressed
await host.sendKeys('ESCAPE');

// THEN: Should focus sendbox
expect(document.activeElement).toBe(
document.querySelector(`[data-testid="${WebChat.testIds.sendBoxTextBox}"]`)
);
await host.snapshot();

// WHEN: Focus suggested actions
await host.sendShiftTab();

// THEN: Should focus the last suggested action
expect(document.activeElement).toBe(lastAction);

// WHEN: Press arrow right again
await host.sendKeys('ARROW_RIGHT');

// THEN: Should wrap around to the first action
expect(document.activeElement).toBe(firstAction);

// WHEN: Press arrow left and space keys
await host.sendKeys('ARROW_LEFT');
await (await directLine.actPostActivity(() => host.sendKeys(' '))).resolveAll();

// THEN: Should wrap around, send last action and focus sendbox
expect(document.activeElement).toBe(
document.querySelector(`[data-testid="${WebChat.testIds.sendBoxTextBox}"]`)
);
await host.snapshot();
});
</script>
</body>

</html>
5 changes: 5 additions & 0 deletions __tests__/html/fluentTheme/suggestedActions.focus.js
Original file line number Diff line number Diff line change
@@ -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'));
});
100 changes: 100 additions & 0 deletions __tests__/html/fluentTheme/suggestedActions.layout.flow.focus.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<!doctype html>
<html lang="en-US">

<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react@16.8.6/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.6/umd/react-dom.production.min.js"></script>
<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>
<script crossorigin="anonymous" src="/__dist__/botframework-webchat-fluent-theme.production.min.js"></script>
</head>

<body>
<main id="webchat"></main>
<script type="text/babel">
run(async function () {
const {
React,
ReactDOM: { render },
WebChat: { FluentThemeProvider, ReactWebChat }
} = window; // Imports in UMD fashion.

const { directLine, store } = testHelpers.createDirectLineEmulator();

const App = () => (
<ReactWebChat directLine={directLine} store={store} styleOptions={{ suggestedActionLayout: 'flow' }} />
);

render(
<FluentThemeProvider>
<App />
</FluentThemeProvider>,
document.getElementById('webchat')
);

await pageConditions.uiConnected();

await directLine.emulateIncomingActivity({
type: 'message',
textFormat: 'plain',
text: 'Please select one of the actions below',
suggestedActions: {
actions: [
{ title: 'One', value: 'One', type: 'imBack' },
{ title: 'Two', value: 'Two', type: 'imBack' },
{ title: 'Three', value: 'Three', type: 'imBack' },
{ title: 'Four', value: 'Four', type: 'imBack' },
{ title: 'Five', value: 'Five', type: 'imBack' }
],
to: []
}
});

document.querySelector(`[data-testid="${WebChat.testIds.sendBoxTextBox}"]`).focus();

// WHEN: Focus suggested actions
await host.sendShiftTab();

// THEN: Should focus first suggested action
await host.snapshot();
const firstAction = document.activeElement;

// WHEN: Press arrow right key four times:
await host.sendKeys('ARROW_RIGHT'); // 2nd
await host.sendKeys('ARROW_RIGHT'); // 3rd
await host.sendKeys('ARROW_RIGHT'); // 4th
await host.sendKeys('ARROW_RIGHT'); // 5th

// THEN: Should focus the last suggested action
expect(document.activeElement?.innerText).toContain('Five');
await host.snapshot();

// WHEN: Press arrow right again
await host.sendKeys('ARROW_RIGHT');

// THEN: Should wrap around to the first action
expect(document.activeElement).toBe(firstAction);
await host.snapshot();

// WHEN: Press arrow left key
await host.sendKeys('ARROW_LEFT');

// THEN: Should wrap around to the last action
expect(document.activeElement?.innerText).toContain('Five');

// WHEN: Press the space key
await (await directLine.actPostActivity(() => host.sendKeys(' '))).resolveAll();

// THEN: Should send last action and focus sendbox
expect(document.activeElement).toBe(
document.querySelector(`[data-testid="${WebChat.testIds.sendBoxTextBox}"]`)
);
await host.snapshot();
});
</script>
</body>

</html>
Original file line number Diff line number Diff line change
@@ -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'));
});
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -36,6 +37,7 @@ function SuggestedAction({
displayText,
image,
imageAlt,
itemIndex,
text,
type,
value
Expand All @@ -44,7 +46,7 @@ function SuggestedAction({
const [{ suggestedAction: suggestedActionStyleSet }] = useStyleSet();
const [disabled] = useDisabled();
const focus = useFocus();
const focusRef = useRef<HTMLButtonElement>(null);
const focusRef = useRovingFocusItemRef<HTMLButtonElement>(itemIndex);
const performCardAction = usePerformCardAction();
const classNames = useStyles(styles);
const scrollToEnd = useScrollToEnd();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<{
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -85,13 +92,16 @@ function SuggestedActions() {
/>
);
});

return (
<SuggestedActionStackedOrFlowContainer
aria-label={localize('SUGGESTED_ACTIONS_LABEL_ALT')}
className={classNames['suggested-actions']}
>
{children}
</SuggestedActionStackedOrFlowContainer>
<RovingFocusProvider onEscapeKey={handleEscapeKey}>
<SuggestedActionStackedOrFlowContainer
aria-label={localize('SUGGESTED_ACTIONS_LABEL_ALT')}
className={classNames['suggested-actions']}
>
{children}
</SuggestedActionStackedOrFlowContainer>
</RovingFocusProvider>
);
}

Expand Down

0 comments on commit c30e4e2

Please sign in to comment.