diff --git a/CHANGELOG.md b/CHANGELOG.md index 687fedbf94..badcfba4db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Resolved [#2661](https://github.com/microsoft/BotFramework-WebChat/issues/2661) and [#5352](https://github.com/microsoft/BotFramework-WebChat/issues/5352). Added speech recognition continuous mode with barge-in support, in PR [#5426](https://github.com/microsoft/BotFramework-WebChat/pull/5426), by [@RushikeshGavali](https://github.com/RushikeshGavali) and [@compulim](https://github.com/compulim) - Set `styleOptions.speechRecognitionContinuous` to `true` with a Web Speech API provider with continuous mode support - Added support of [contentless activity in livestream](https://github.com/microsoft/BotFramework-WebChat/blob/main/docs/LIVESTREAMING.md#scenario-3-interim-activities-with-no-content), in PR [#5430](https://github.com/microsoft/BotFramework-WebChat/pull/5430), by [@compulim](https://github.com/compulim) +- Added sliding dots typing indicator in Fluent theme, in PR [#5447](https://github.com/microsoft/BotFramework-WebChat/pull/5447), by [@compulim](https://github.com/compulim) ### Changed diff --git a/__tests__/__image_snapshots__/html/side-by-side-wide-dark-js-fluent-theme-applied-dark-theme-applied-side-by-side-left-transcript-right-streaming-1-snap.png b/__tests__/__image_snapshots__/html/side-by-side-wide-dark-js-fluent-theme-applied-dark-theme-applied-side-by-side-left-transcript-right-streaming-1-snap.png index 590e4e1e1f..f3cbffe80a 100644 Binary files a/__tests__/__image_snapshots__/html/side-by-side-wide-dark-js-fluent-theme-applied-dark-theme-applied-side-by-side-left-transcript-right-streaming-1-snap.png and b/__tests__/__image_snapshots__/html/side-by-side-wide-dark-js-fluent-theme-applied-dark-theme-applied-side-by-side-left-transcript-right-streaming-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/side-by-side-wide-js-fluent-theme-applied-side-by-side-left-transcript-right-streaming-1-snap.png b/__tests__/__image_snapshots__/html/side-by-side-wide-js-fluent-theme-applied-side-by-side-left-transcript-right-streaming-1-snap.png index 635a25ab28..66c9951a97 100644 Binary files a/__tests__/__image_snapshots__/html/side-by-side-wide-js-fluent-theme-applied-side-by-side-left-transcript-right-streaming-1-snap.png and b/__tests__/__image_snapshots__/html/side-by-side-wide-js-fluent-theme-applied-side-by-side-left-transcript-right-streaming-1-snap.png differ diff --git a/__tests__/html/fluentTheme/side-by-side.wide.dark.html b/__tests__/html/fluentTheme/side-by-side.wide.dark.html index 20d2d320c3..4ab51fbd35 100644 --- a/__tests__/html/fluentTheme/side-by-side.wide.dark.html +++ b/__tests__/html/fluentTheme/side-by-side.wide.dark.html @@ -68,6 +68,10 @@ Fluent: { FluentProvider, createDarkTheme } } = window; // Imports in UMD fashion. + await host.sendDevToolsCommand('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] + }); + await host.windowSize(1460, 700, document.getElementById('webchat')); let timestampStart = new Date(2020, 7, 9).getTime(); @@ -104,17 +108,17 @@ const waveSvg = `data:image/svg+xml;utf8,${encodeURIComponent(` - - + - - + - - + @@ -576,35 +580,35 @@ """Create a beautiful visualization of sine waves with different frequencies.""" # Generate time points t = np.linspace(0, 10, 1000) - + # Create waves with different frequencies and phases wave1 = np.sin(t) wave2 = 0.5 * np.sin(2 * t + np.pi/4) wave3 = 0.3 * np.sin(3 * t + np.pi/3) - + # Combine waves combined = wave1 + wave2 + wave3 - + # Create a stylish plot plt.style.use('seaborn-darkgrid') plt.figure(figsize=(12, 8)) - + # Plot individual waves plt.plot(t, wave1, label='Primary Wave', alpha=0.5) plt.plot(t, wave2, label='Second Harmonic', alpha=0.5) plt.plot(t, wave3, label='Third Harmonic', alpha=0.5) - + # Plot combined wave with a thicker line - plt.plot(t, combined, 'r-', - label='Combined Wave', + plt.plot(t, combined, 'r-', + label='Combined Wave', linewidth=2) - + plt.title('Harmonic Wave Composition', fontsize=14) plt.xlabel('Time', fontsize=12) plt.ylabel('Amplitude', fontsize=12) plt.legend() plt.grid(True, alpha=0.3) - + # Show the plot plt.tight_layout() plt.show() @@ -639,7 +643,7 @@ { "@type": "LikeAction", actionStatus: "CompletedActionStatus", - target: { + target: { "@type": "EntryPoint", urlTemplate: "ms-directline://postback?interaction=like" } @@ -681,7 +685,7 @@ { "@type": "LikeAction", actionStatus: "PotentialActionStatus", - target: { + target: { "@type": "EntryPoint", urlTemplate: "ms-directline://postback?interaction=like" } diff --git a/__tests__/html/fluentTheme/side-by-side.wide.html b/__tests__/html/fluentTheme/side-by-side.wide.html index d8024beea6..423e5fbd46 100644 --- a/__tests__/html/fluentTheme/side-by-side.wide.html +++ b/__tests__/html/fluentTheme/side-by-side.wide.html @@ -77,6 +77,10 @@ WebChat: { FluentThemeProvider, ReactWebChat } } = window; // Imports in UMD fashion. + await host.sendDevToolsCommand('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] + }); + await host.windowSize(1460, 700, document.getElementById('webchat')); let timestampStart = new Date(2020, 7, 9).getTime(); @@ -114,17 +118,17 @@ const waveSvg = `data:image/svg+xml;utf8,${encodeURIComponent(` - - + - - + - - + @@ -586,35 +590,35 @@ """Create a beautiful visualization of sine waves with different frequencies.""" # Generate time points t = np.linspace(0, 10, 1000) - + # Create waves with different frequencies and phases wave1 = np.sin(t) wave2 = 0.5 * np.sin(2 * t + np.pi/4) wave3 = 0.3 * np.sin(3 * t + np.pi/3) - + # Combine waves combined = wave1 + wave2 + wave3 - + # Create a stylish plot plt.style.use('seaborn-darkgrid') plt.figure(figsize=(12, 8)) - + # Plot individual waves plt.plot(t, wave1, label='Primary Wave', alpha=0.5) plt.plot(t, wave2, label='Second Harmonic', alpha=0.5) plt.plot(t, wave3, label='Third Harmonic', alpha=0.5) - + # Plot combined wave with a thicker line - plt.plot(t, combined, 'r-', - label='Combined Wave', + plt.plot(t, combined, 'r-', + label='Combined Wave', linewidth=2) - + plt.title('Harmonic Wave Composition', fontsize=14) plt.xlabel('Time', fontsize=12) plt.ylabel('Amplitude', fontsize=12) plt.legend() plt.grid(True, alpha=0.3) - + # Show the plot plt.tight_layout() plt.show() @@ -649,7 +653,7 @@ { "@type": "LikeAction", actionStatus: "CompletedActionStatus", - target: { + target: { "@type": "EntryPoint", urlTemplate: "ms-directline://postback?interaction=like" } @@ -691,7 +695,7 @@ { "@type": "LikeAction", actionStatus: "PotentialActionStatus", - target: { + target: { "@type": "EntryPoint", urlTemplate: "ms-directline://postback?interaction=like" } diff --git a/__tests__/html2/fluentTheme/typingIndicator.html b/__tests__/html2/fluentTheme/typingIndicator.html new file mode 100644 index 0000000000..9193facc36 --- /dev/null +++ b/__tests__/html2/fluentTheme/typingIndicator.html @@ -0,0 +1,108 @@ + + + + + + + + + + + + +
+ + + + diff --git a/__tests__/html2/fluentTheme/typingIndicator.html.snap-1.png b/__tests__/html2/fluentTheme/typingIndicator.html.snap-1.png new file mode 100644 index 0000000000..71c8265dc5 Binary files /dev/null and b/__tests__/html2/fluentTheme/typingIndicator.html.snap-1.png differ diff --git a/__tests__/html2/fluentTheme/typingIndicator.html.snap-2.png b/__tests__/html2/fluentTheme/typingIndicator.html.snap-2.png new file mode 100644 index 0000000000..b4218ef33f Binary files /dev/null and b/__tests__/html2/fluentTheme/typingIndicator.html.snap-2.png differ diff --git a/__tests__/html2/fluentTheme/typingIndicator.livestream.html b/__tests__/html2/fluentTheme/typingIndicator.livestream.html new file mode 100644 index 0000000000..abedbe5bff --- /dev/null +++ b/__tests__/html2/fluentTheme/typingIndicator.livestream.html @@ -0,0 +1,9 @@ + + + + + + + diff --git a/__tests__/html2/fluentTheme/typingIndicator.livestream.html.snap-1.png b/__tests__/html2/fluentTheme/typingIndicator.livestream.html.snap-1.png new file mode 100644 index 0000000000..71c8265dc5 Binary files /dev/null and b/__tests__/html2/fluentTheme/typingIndicator.livestream.html.snap-1.png differ diff --git a/__tests__/html2/fluentTheme/typingIndicator.livestream.html.snap-2.png b/__tests__/html2/fluentTheme/typingIndicator.livestream.html.snap-2.png new file mode 100644 index 0000000000..b4218ef33f Binary files /dev/null and b/__tests__/html2/fluentTheme/typingIndicator.livestream.html.snap-2.png differ diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 4fa084f891..04a9018c96 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -122,6 +122,7 @@ Following is the list of hooks supported by Web Chat API. - [`useSendTypingIndicator`](#usesendtypingindicator) - [`useSendStatusByActivityKey`](#usesendstatusbyactivitykey) - [`useSetNotification`](#usesetnotification) +- [`useShouldReduceMotion`](#useshouldreducemotion) - [`useShouldSpeakIncomingActivity`](#useshouldspeakincomingactivity) - [`useStartDictate`](#usestartdictate) - [`useStopDictate`](#usestopdictate) @@ -1289,6 +1290,24 @@ The `message` field will be processed through an internal Markdown renderer. If The toast UI will [debounce notifications](https://github.com/microsoft/BotFramework-WebChat/tree/main/docs/NOTIFICATION.md#postponing-changes-via-debounce) that update too frequently. +## `useShouldReduceMotion` + +> Only available on `botframework-webchat-components` package. + +> New in 4.19.0. + + +```js +useShouldReduceMotion(): readonly [boolean] +``` + + +This state hook is a helper hook that will return `true` if the browser has [reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) enabled, otherwise, `false`. + +This hook is based on [`matchMedia`](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) and provides a React hook friendly wrapper for listening to state change. + +If it is possible to slowdown or pause animation using CSS, always use the [CSS media feature `(prefers-reduced-motion: reduce)`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) instead. This hook is the last resort when CSS cannot be used to stop animation, such as SMIL animation. + ## `useShouldSpeakIncomingActivity` diff --git a/packages/component/src/Assets/TypingAnimation.js b/packages/component/src/Assets/TypingAnimation.js index 7ae6720c34..e39b4d37ef 100644 --- a/packages/component/src/Assets/TypingAnimation.js +++ b/packages/component/src/Assets/TypingAnimation.js @@ -4,6 +4,7 @@ import React from 'react'; import ScreenReaderText from '../ScreenReaderText'; import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject'; +import testIds from '../testIds'; import useStyleSet from '../hooks/useStyleSet'; const { useDirection, useLocalizer } = hooks; @@ -31,6 +32,7 @@ const TypingAnimation = () => { rootClassName, typingAnimationStyleSet + '' )} + data-testid={testIds.typingIndicator} /> ); diff --git a/packages/component/src/Composer.tsx b/packages/component/src/Composer.tsx index e2251bd988..30d8b8981e 100644 --- a/packages/component/src/Composer.tsx +++ b/packages/component/src/Composer.tsx @@ -46,6 +46,7 @@ import { type HTMLContentTransformMiddleware } from './providers/HTMLContentTran import SendBoxComposer from './providers/internal/SendBox/SendBoxComposer'; import { LiveRegionTwinComposer } from './providers/LiveRegionTwin'; import ModalDialogComposer from './providers/ModalDialog/ModalDialogComposer'; +import ReducedMotionComposer from './providers/ReducedMotion/ReducedMotionComposer'; import useTheme from './providers/Theme/useTheme'; import createDefaultSendBoxMiddleware from './SendBox/createMiddleware'; import createDefaultSendBoxToolbarMiddleware from './SendBoxToolbar/createMiddleware'; @@ -462,18 +463,20 @@ const Composer = ({ - - {children} - {onTelemetry && } - + + + {children} + {onTelemetry && } + + diff --git a/packages/component/src/hooks/index.ts b/packages/component/src/hooks/index.ts index 80cbff59f6..ca573641f4 100644 --- a/packages/component/src/hooks/index.ts +++ b/packages/component/src/hooks/index.ts @@ -1,4 +1,5 @@ import { useTransformHTMLContent } from '../providers/HTMLContentTransformCOR/index'; +import useShouldReduceMotion from '../providers/ReducedMotion/useShouldReduceMotion'; import useDictateAbortable from './useDictateAbortable'; import useFocus from './useFocus'; import useMakeThumbnail from './useMakeThumbnail'; @@ -41,6 +42,7 @@ export { useSendFiles, // We are overwriting the `useSendMessage` hook from bf-wc-api and adding thumbnailing support. useSendMessage, + useShouldReduceMotion, useStyleSet, useTextBoxSubmit, useTextBoxValue, diff --git a/packages/component/src/providers/ReducedMotion/ReducedMotionComposer.tsx b/packages/component/src/providers/ReducedMotion/ReducedMotionComposer.tsx new file mode 100644 index 0000000000..f331f0dcff --- /dev/null +++ b/packages/component/src/providers/ReducedMotion/ReducedMotionComposer.tsx @@ -0,0 +1,35 @@ +import React, { memo, useEffect, useMemo, useState, type ReactNode } from 'react'; + +import { type ContextOf } from '../../types/ContextOf'; +import Context from './private/Context'; + +type ContextType = ContextOf; + +type ReducedMotionComposerProps = Readonly<{ + children?: ReactNode | undefined; +}>; + +const ReducedMotionComposer = memo(({ children }: ReducedMotionComposerProps) => { + const shouldReduceMotionQueryList = useMemo(() => matchMedia?.('(prefers-reduced-motion: reduce)'), []); + const [shouldReduceMotion, setShouldReduceMotion] = useState( + () => shouldReduceMotionQueryList?.matches ?? false + ); + + const shouldReduceMotionState = useMemo(() => Object.freeze([shouldReduceMotion] as const), [shouldReduceMotion]); + + const context = useMemo(() => Object.freeze({ shouldReduceMotionState }), [shouldReduceMotionState]); + + useEffect(() => { + const handleChange = ({ matches }: MediaQueryListEvent) => setShouldReduceMotion(matches); + + shouldReduceMotionQueryList.addEventListener('change', handleChange); + + return () => shouldReduceMotionQueryList.removeEventListener('change', handleChange); + }, [setShouldReduceMotion, shouldReduceMotionQueryList]); + + return {children}; +}); + +ReducedMotionComposer.displayName = 'ReducedMotionComposer'; + +export default ReducedMotionComposer; diff --git a/packages/component/src/providers/ReducedMotion/private/Context.ts b/packages/component/src/providers/ReducedMotion/private/Context.ts new file mode 100644 index 0000000000..e0866715b4 --- /dev/null +++ b/packages/component/src/providers/ReducedMotion/private/Context.ts @@ -0,0 +1,22 @@ +import { createContext } from 'react'; + +type ContextType = Readonly<{ + shouldReduceMotionState: readonly [boolean]; +}>; + +type ContextAsGetter> = + T extends Record ? Record : never; + +const defaultContextValue: ContextAsGetter = { + shouldReduceMotionState: { + get() { + throw new Error('shouldReduceMotionState cannot be used outside of .'); + } + } +}; + +const Context = createContext(Object.create({}, defaultContextValue)); + +Context.displayName = 'ReducedMotionComposer'; + +export default Context; diff --git a/packages/component/src/providers/ReducedMotion/private/useContext.ts b/packages/component/src/providers/ReducedMotion/private/useContext.ts new file mode 100644 index 0000000000..db728272cb --- /dev/null +++ b/packages/component/src/providers/ReducedMotion/private/useContext.ts @@ -0,0 +1,8 @@ +import { useContext as useReactContext } from 'react'; + +import { type ContextOf } from '../../../types/ContextOf'; +import Context from './Context'; + +export default function useContext(): ContextOf { + return useReactContext(Context); +} diff --git a/packages/component/src/providers/ReducedMotion/useShouldReduceMotion.ts b/packages/component/src/providers/ReducedMotion/useShouldReduceMotion.ts new file mode 100644 index 0000000000..e21854ec3d --- /dev/null +++ b/packages/component/src/providers/ReducedMotion/useShouldReduceMotion.ts @@ -0,0 +1,5 @@ +import useContext from './private/useContext'; + +export default function useShouldReduceMotion(): ReturnType['shouldReduceMotionState'] { + return useContext().shouldReduceMotionState; +} diff --git a/packages/component/src/testIds.ts b/packages/component/src/testIds.ts index d58f326907..1f7486a6e5 100644 --- a/packages/component/src/testIds.ts +++ b/packages/component/src/testIds.ts @@ -3,6 +3,7 @@ const testIds = { copyButton: 'copy button', sendBoxSpeechBox: 'send box speech box', sendBoxTextBox: 'send box text area', + typingIndicator: 'typing indicator', viewCodeButton: 'view code button' }; diff --git a/packages/fluent-theme/src/components/activity/ActivityLoader.tsx b/packages/fluent-theme/src/components/activity/ActivityLoader.tsx index 850c57b716..f1482bb81a 100644 --- a/packages/fluent-theme/src/components/activity/ActivityLoader.tsx +++ b/packages/fluent-theme/src/components/activity/ActivityLoader.tsx @@ -3,23 +3,17 @@ import cx from 'classnames'; import React, { Fragment, memo, type ReactNode } from 'react'; import { useVariantClassName } from '../../styles'; +import SlidingDots from '../assets/SlidingDots'; import styles from './ActivityLoader.module.css'; -const loadingAnimationUrl = - 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCA0MDAgMjAiPjxkZWZzPjxsaW5lYXJHcmFkaWVudCBpZD0iYSIgeDE9IjAiIHgyPSIxMDAlIiB5MT0iMCIgeTI9IjAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAlIj48YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSJzdG9wLWNvbG9yIiBkdXI9IjJzIiBrZXlUaW1lcz0iMDswLjI7MC4zMzswLjU7MC42NjswLjg7MSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iI2FkNWFlMTsjYWQ1YWUxOyMwRTk0RTE7IzBFOTRFMTsjNjY5ZmMyOyM2NjlmYzI7I2FkNWFlMSIvPjwvc3RvcD48c3RvcCBvZmZzZXQ9IjUwJSI+PGFuaW1hdGUgYXR0cmlidXRlTmFtZT0ic3RvcC1jb2xvciIgZHVyPSIycyIga2V5VGltZXM9IjA7MC4yOzAuMzM7MC41OzAuNjY7MC44OzEiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiB2YWx1ZXM9IiNlOTYxOGQ7I2U5NjE4ZDsjNTdBQjgyOyM1N0FCODI7IzYzNzdlMDsjNjM3N2UwOyNlOTYxOGQiLz48L3N0b3A+PHN0b3Agb2Zmc2V0PSIxMDAlIj48YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSJzdG9wLWNvbG9yIiBkdXI9IjJzIiBrZXlUaW1lcz0iMDswLjI7MC4zMzswLjU7MC42NjswLjg7MSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iI2ZkOWU1ZjsjZmQ5ZTVmOyNDNkMyMjU7I0M2QzIyNTsjOWI4MGVjOyM5YjgwZWM7I2ZkOWU1ZiIvPjwvc3RvcD48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48ZyBmaWxsPSJ1cmwoI2EpIj48cmVjdCBoZWlnaHQ9IjIwIiByeD0iMTAiPjxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9IngiIGR1cj0iMnMiIGtleVRpbWVzPSIwOzAuNTswLjY2OzEiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiB2YWx1ZXM9IjI2OzI2OzA7MCIvPjxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9IndpZHRoIiBkdXI9IjJzIiBrZXlUaW1lcz0iMDswLjI7MC4zMzswLjU7MC42NjsxIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgdmFsdWVzPSIyMDsyMDszMDszMDsyMDsyMCIvPjxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9Im9wYWNpdHkiIGR1cj0iMnMiIGtleVRpbWVzPSIwOzAuNTswLjY2OzEiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiB2YWx1ZXM9IjE7MTswOzAiLz48L3JlY3Q+PHJlY3QgaGVpZ2h0PSIyMCIgcng9IjEwIj48YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSJ4IiBkdXI9IjJzIiBrZXlUaW1lcz0iMDswLjI7MC4zMzswLjU7MC42NjswLjg7MSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iNjI7NjI7NzI7NzI7MjY7MjY7MCIvPjxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9IndpZHRoIiBkdXI9IjJzIiBrZXlUaW1lcz0iMDswLjI7MC4zMzswLjU7MC42NjswLjg7MSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iMTA0OzEwNDsyMDsyMDs3MDs3MDsyMCIvPjxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9Im9wYWNpdHkiIGR1cj0iMnMiIGtleVRpbWVzPSIwOzAuODsxIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgdmFsdWVzPSIxOzE7MCIvPjwvcmVjdD48cmVjdCBoZWlnaHQ9IjIwIiByeD0iMTAiPjxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9IngiIGR1cj0iMnMiIGtleVRpbWVzPSIwOzAuMjswLjMzOzAuNTswLjY2OzAuODsxIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgdmFsdWVzPSIxODI7MTgyOzEwODsxMDg7MTEyOzExMjsyNiIvPjxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9IndpZHRoIiBkdXI9IjJzIiBrZXlUaW1lcz0iMDswLjI7MC4zMzswLjU7MC42NjsxIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgdmFsdWVzPSIyMDsyMDs2MDs2MDsyMDsyMCIvPjwvcmVjdD48cmVjdCBoZWlnaHQ9IjIwIiByeD0iMTAiPjxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9IngiIGR1cj0iMnMiIGtleVRpbWVzPSIwOzAuMjswLjMzOzAuNTswLjY2OzAuODsxIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgdmFsdWVzPSIyMTg7MjE4OzE4NDsxODQ7MTQ4OzE0ODs2MiIvPjxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9IndpZHRoIiBkdXI9IjJzIiBrZXlUaW1lcz0iMDswLjI7MC4zMzswLjU7MC42NjswLjg7MSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iNjA7NjA7ODA7ODA7NDA7NDA7MTA0Ii8+PC9yZWN0PjxyZWN0IGhlaWdodD0iMjAiIHJ4PSIxMCI+PGFuaW1hdGUgYXR0cmlidXRlTmFtZT0ieCIgZHVyPSIycyIga2V5VGltZXM9IjA7MC4yOzAuMzM7MC41OzAuNjY7MC44OzEiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiB2YWx1ZXM9IjI5NDsyOTQ7MjgwOzI4MDsyMDQ7MjA0OzE4MiIvPjxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9IndpZHRoIiBkdXI9IjJzIiBrZXlUaW1lcz0iMDswLjI7MC4zMzswLjU7MC42NjswLjg7MSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iNDA7NDA7MjA7MjA7ODA7ODA7MjAiLz48L3JlY3Q+PHJlY3QgaGVpZ2h0PSIyMCIgcng9IjEwIj48YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSJ4IiBkdXI9IjJzIiBrZXlUaW1lcz0iMDswLjI7MC4zMzswLjU7MC42NjswLjg7MSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iMzUwOzM1MDszMTY7MzE2OzMwMDszMDA7MjE4Ii8+PGFuaW1hdGUgYXR0cmlidXRlTmFtZT0id2lkdGgiIGR1cj0iMnMiIGtleVRpbWVzPSIwOzAuMjswLjMzOzAuNTswLjY2OzAuODsxIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgdmFsdWVzPSIyMDsyMDs2MDs2MDsyMDsyMDs2MCIvPjwvcmVjdD48cmVjdCBoZWlnaHQ9IjIwIiByeD0iMTAiPjxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9IngiIGR1cj0iMnMiIGtleVRpbWVzPSIwOzAuMjswLjMzOzAuNTswLjY2OzAuODsxIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgdmFsdWVzPSIzODY7Mzg2OzM5MjszOTI7MzM2OzMzNjsyOTQiLz48YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSJ3aWR0aCIgZHVyPSIycyIga2V5VGltZXM9IjA7MC41OzAuNjY7MSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iMjA7MjA7NDA7NDAiLz48YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSJvcGFjaXR5IiBkdXI9IjJzIiBrZXlUaW1lcz0iMDswLjU7MC42NjsxIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgdmFsdWVzPSIwOzA7MTsxIi8+PC9yZWN0PjxyZWN0IHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgcng9IjEwIj48YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSJ4IiBkdXI9IjJzIiBrZXlUaW1lcz0iMDswLjI7MC4zMzswLjU7MC42NjswLjg7MSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHZhbHVlcz0iNDIyOzQyMjs0Mjg7NDI4OzM5MjszOTI7MzUwIi8+PGFuaW1hdGUgYXR0cmlidXRlTmFtZT0ib3BhY2l0eSIgZHVyPSIycyIga2V5VGltZXM9IjA7MC44OzEiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiB2YWx1ZXM9IjA7MDsxIi8+PC9yZWN0PjwvZz48L3N2Zz4='; - function FluentActivityLoader({ children }: Readonly<{ children?: ReactNode | undefined }>) { const classNames = useStyles(styles); const variantClassName = useVariantClassName(classNames); + return ( {children} - + ); } diff --git a/packages/fluent-theme/src/components/assets/SlidingDots.tsx b/packages/fluent-theme/src/components/assets/SlidingDots.tsx new file mode 100644 index 0000000000..46a46dc19f --- /dev/null +++ b/packages/fluent-theme/src/components/assets/SlidingDots.tsx @@ -0,0 +1,62 @@ +import { hooks } from 'botframework-webchat-component'; +import React, { memo, useCallback, useEffect, useRef } from 'react'; +import { useRefFrom } from 'use-ref-from'; + +const { useLocalizer, useShouldReduceMotion } = hooks; + +type SlidingDotsProps = Readonly<{ className: string }>; + +const SLIDING_DOTS_SVG_STRING = + ''; +const SLIDING_DOTS_SVG_URL = URL.createObjectURL(new Blob([SLIDING_DOTS_SVG_STRING], { type: 'image/svg+xml' })); + +const SlidingDots = ({ className }: SlidingDotsProps) => { + const [shouldReduceMotion] = useShouldReduceMotion(); + const localize = useLocalizer(); + const objectElementRef = useRef(null); + + const altText = localize('TYPING_INDICATOR_ALT'); + const shouldReduceMotionRef = useRefFrom(shouldReduceMotion); + + const pauseAnimations = useCallback(() => { + const contentDocument = objectElementRef.current?.contentDocument; + const svgElement = contentDocument?.documentElement; + const { SVGSVGElement } = contentDocument?.defaultView || {}; + + SVGSVGElement && svgElement instanceof SVGSVGElement && svgElement.pauseAnimations(); + }, [objectElementRef]); + + const unpauseAnimations = useCallback(() => { + const contentDocument = objectElementRef.current?.contentDocument; + const svgElement = contentDocument?.documentElement; + const { SVGSVGElement } = contentDocument?.defaultView || {}; + + SVGSVGElement && svgElement instanceof SVGSVGElement && svgElement.unpauseAnimations(); + }, [objectElementRef]); + + const pauseOrUnpauseAnimations = useCallback( + () => (shouldReduceMotionRef.current ? pauseAnimations() : unpauseAnimations()), + [pauseAnimations, shouldReduceMotionRef, unpauseAnimations] + ); + + useEffect(pauseOrUnpauseAnimations, [ + pauseOrUnpauseAnimations, + // Call "pauseOrUnpauseAnimations()" when "shouldReduceMotion" change. + shouldReduceMotion + ]); + + return ( + + ); +}; + +SlidingDots.displayName = 'SlidingDots'; + +export default memo(SlidingDots); diff --git a/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.module.css b/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.module.css new file mode 100644 index 0000000000..116ac40a3c --- /dev/null +++ b/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.module.css @@ -0,0 +1,11 @@ +:global(.webchat-fluent) .sliding-dots-typing-indicator { + align-self: start; + display: flex; + height: 16px; + margin: auto var(--webchat-spacingHorizontalMNudge); +} + +:global(.webchat-fluent) .sliding-dots-typing-indicator__image { + height: 6px; + width: auto; +} diff --git a/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.tsx b/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.tsx new file mode 100644 index 0000000000..5b35eae5d3 --- /dev/null +++ b/packages/fluent-theme/src/components/typingIndicator/SlidingDotsTypingIndicator.tsx @@ -0,0 +1,24 @@ +import { testIds } from 'botframework-webchat-component'; +import { useStyles } from 'botframework-webchat-styles/react'; +import cx from 'classnames'; +import React, { memo } from 'react'; + +import { useVariantClassName } from '../../styles'; +import SlidingDots from '../assets/SlidingDots'; +import styles from './SlidingDotsTypingIndicator.module.css'; + +function SlidingDotsTypingIndicator() { + const classNames = useStyles(styles); + const variantClassName = useVariantClassName(classNames); + + return ( +
+ +
+ ); +} + +export default memo(SlidingDotsTypingIndicator); diff --git a/packages/fluent-theme/src/external.umd/botframework-webchat-component/index.ts b/packages/fluent-theme/src/external.umd/botframework-webchat-component/index.ts index c58420a36e..930306df68 100644 --- a/packages/fluent-theme/src/external.umd/botframework-webchat-component/index.ts +++ b/packages/fluent-theme/src/external.umd/botframework-webchat-component/index.ts @@ -1,4 +1,5 @@ module.exports = { Components: (globalThis as any).WebChat.Components, - hooks: (globalThis as any).WebChat.hooks + hooks: (globalThis as any).WebChat.hooks, + testIds: (globalThis as any).WebChat.testIds }; diff --git a/packages/fluent-theme/src/private/FluentThemeProvider.tsx b/packages/fluent-theme/src/private/FluentThemeProvider.tsx index f952150fd8..d765b9039a 100644 --- a/packages/fluent-theme/src/private/FluentThemeProvider.tsx +++ b/packages/fluent-theme/src/private/FluentThemeProvider.tsx @@ -1,4 +1,4 @@ -import { type ActivityMiddleware, type StyleOptions } from 'botframework-webchat-api'; +import { type ActivityMiddleware, type StyleOptions, type TypingIndicatorMiddleware } from 'botframework-webchat-api'; import { DecoratorComposer, DecoratorMiddleware } from 'botframework-webchat-api/decorator'; import { Components } from 'botframework-webchat-component'; import { WebChatDecorator } from 'botframework-webchat-component/decorator'; @@ -6,13 +6,14 @@ import React, { memo, type ReactNode } from 'react'; import { ActivityDecorator } from '../components/activity'; import ActivityLoader from '../components/activity/ActivityLoader'; +import { isLinerMessageActivity, LinerMessageActivity } from '../components/linerActivity'; import { isPreChatMessageActivity, PreChatMessageActivity } from '../components/preChatActivity'; import { PrimarySendBox } from '../components/sendBox'; import { TelephoneKeypadProvider } from '../components/telephoneKeypad'; import { WebChatTheme } from '../components/theme'; +import SlidingDotsTypingIndicator from '../components/typingIndicator/SlidingDotsTypingIndicator'; import { createStyles } from '../styles'; import VariantComposer, { VariantList } from './VariantComposer'; -import { isLinerMessageActivity, LinerMessageActivity } from '../components/linerActivity'; const { ThemeProvider } = Components; @@ -55,6 +56,13 @@ const fluentStyleOptions: StyleOptions = Object.freeze({ feedbackActionsPlacement: 'activity-actions' }); +const typingIndicatorMiddleware = Object.freeze([ + () => + next => + (...args) => + args[0].visible ? : next(...args) +] satisfies TypingIndicatorMiddleware[]); + const FluentThemeProvider = ({ children, variant = 'fluent' }: Props) => ( @@ -64,6 +72,7 @@ const FluentThemeProvider = ({ children, variant = 'fluent' }: Props) => ( sendBoxMiddleware={sendBoxMiddleware} styleOptions={fluentStyleOptions} styles={styles} + typingIndicatorMiddleware={typingIndicatorMiddleware} > {children} diff --git a/packages/test/page-object/src/globals/pageElements/typingIndicator.js b/packages/test/page-object/src/globals/pageElements/typingIndicator.js index 6dbfd24942..1137d5eccb 100644 --- a/packages/test/page-object/src/globals/pageElements/typingIndicator.js +++ b/packages/test/page-object/src/globals/pageElements/typingIndicator.js @@ -1,5 +1,7 @@ +import { testIds } from 'botframework-webchat'; + import root from './root'; export default function typingIndicator() { - return root().querySelector('.webchat__typing-indicator'); + return root().querySelector(`[data-testid="${testIds.typingIndicator}"]`); }