Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/6052-fix-css-modules-small-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Changed

- Converted the screen reader text, live region activity, fixed-width image and send failed retry link to CSS Modules with `webchat__` class names; in the Fluent theme, the send failed retry link now uses Fluent's accent color, in PR [#6052](https://github.com/microsoft/BotFramework-WebChat/pull/6052), by [@OEvgeny](https://github.com/OEvgeny)
71 changes: 71 additions & 0 deletions __tests__/html2/activityStatus/sendFailedRetry.accent.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="en-US">
<head>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main id="webchat"></main>
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"react": "/__dist__/packages/bundle/static/react.js",
"react-dom": "/__dist__/packages/bundle/static/react-dom.js"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { waitFor } from '@testduet/wait-for';
import { createStoreWithOptions, renderWebChat } from 'botframework-webchat';

// TODO: This is for `createDirectLineEmulator` only, should find ways to eliminate this line.
window.WebChat = { createStoreWithOptions };

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

// GIVEN: A custom accent color.
renderWebChat(
{
directLine,
store,
styleOptions: {
accent: 'DarkOrange',
hideSendBox: true
}
},
document.getElementById('webchat')
);

await pageConditions.uiConnected();

// WHEN: The user sends a message and it fails to send.
const sendMessage = await directLine.emulateOutgoingActivity('Hello, World!');

sendMessage.rejectPostActivity(new Error('artificial error'));

await waitFor(() => expect(pageElements.activityStatuses()[0].innerText).toBe('Send failed. Retry.'));

// THEN: The "Retry" link is dark orange.
await host.snapshot('local');

// WHEN: The "Retry" link is focused.
const retryButton = pageElements.activityStatuses()[0].querySelector('button');

retryButton.focus();

await waitFor(() => expect(document.activeElement).toBe(retryButton));

// THEN: The dark orange "Retry" link has a focus indicator.
await host.snapshot('local');
},
{ ignoreErrors: 'artificial error' }
);
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions packages/component/src/LiveRegion/LiveRegionActivity.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
:global(.webchat) .live-region-activity {
color: transparent;
height: 1px;
opacity: 0;
overflow: hidden;
position: absolute;
top: 0;
white-space: nowrap;
width: 1px;
}
21 changes: 4 additions & 17 deletions packages/component/src/LiveRegion/LiveRegionActivity.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,17 @@
/* eslint no-magic-numbers: ["error", { "ignore": [2] }] */

import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot';
import classNames from 'classnames';
import { useStyles, webChatPrefixedClassName } from '@msinternal/botframework-webchat-styles/react';
import React, { Fragment, useMemo } from 'react';
import { any, object, pipe, readonly } from 'valibot';

import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject';
import useLocalizeActivityGreetingAlt from '../hooks/internal/useLocalizeActivityGreetingAlt.js';
import useRenderMarkdownAsHTML from '../hooks/useRenderMarkdownAsHTML';
import activityAltText from '../Utils/activityAltText';
import styles from './LiveRegionActivity.module.css';
import LiveRegionAttachments from './private/LiveRegionAttachments';
import LiveRegionSuggestedActions from './private/LiveRegionSuggestedActions';

const ROOT_STYLE = {
'&.webchat__live-region-activity': {
color: 'transparent',
height: 1,
opacity: 0,
overflow: 'hidden',
position: 'absolute',
top: 0,
whiteSpace: 'nowrap',
width: 1
}
};

const liveRegionActivityPropsSchema = pipe(
object({
activity: any()
Expand All @@ -41,16 +28,16 @@ function LiveRegionActivity(props: LiveRegionActivityProps) {
const fallbackText: string | undefined =
type === 'message' ? activity.channelData['webchat:fallback-text'] : undefined;
const [greetingAlt] = useLocalizeActivityGreetingAlt()(activity);
const classNames = useStyles(styles, webChatPrefixedClassName);
const renderMarkdownAsHTML = useRenderMarkdownAsHTML('accessible name');
const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + '';
const textAlt = useMemo(() => activityAltText(activity, renderMarkdownAsHTML), [activity, renderMarkdownAsHTML]);

const validFallbackText = fallbackText && typeof fallbackText === 'string';

// Lines are `role="none"`: on Windows, generic `<div>` and `<p>` are exposed as "group", screen readers would read "group" before each line.
// The message itself stays in the accessibility tree, as required by `aria-atomic`.
return (
<div aria-atomic={true} className={classNames('webchat__live-region-activity', rootClassName)}>
<div aria-atomic={true} className={classNames['live-region-activity']}>
<div role="none">{greetingAlt}</div>
{validFallbackText ? (
<div role="none">{fallbackText}</div>
Expand Down
17 changes: 17 additions & 0 deletions packages/component/src/ScreenReaderText.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/* .sr-only - This component is intended to be invisible to the visual Web Chat user, but read by the AT when using a screen reader */
:global(.webchat) .screen-reader-text {
color: transparent;
height: 1px;
overflow: hidden;
position: absolute;
/*
* We need to set top: 0, otherwise, it will repro:
* - Run NVDA
* - Make the transcript long enough to show the scrollbar
* - Press SHIFT-TAB, focus on upload button
* - Press up arrow multiple times
*/
top: 0;
white-space: nowrap;
width: 1px;
}
25 changes: 5 additions & 20 deletions packages/component/src/ScreenReaderText.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,11 @@
/* eslint react/forbid-dom-props: ["off"] */

import { validateProps } from '@msinternal/botframework-webchat-react-valibot';
import { useStyles, webChatPrefixedClassName } from '@msinternal/botframework-webchat-styles/react';
import React, { forwardRef, memo } from 'react';

import { boolean, object, optional, pipe, readonly, string, type InferInput } from 'valibot';
import { useStyleToEmotionObject } from './hooks/internal/styleToEmotionObject';

const ROOT_STYLE = {
// .sr-only - This component is intended to be invisible to the visual Web Chat user, but read by the AT when using a screen reader
color: 'transparent',
height: 1,
overflow: 'hidden',
position: 'absolute',
// We need to set top: 0, otherwise, it will repro:
// - Run NVDA
// - Make the transcript long enough to show the scrollbar
// - Press SHIFT-TAB, focus on upload button
// - Press up arrow multiple times
top: 0,
whiteSpace: 'nowrap',
width: 1
};

import styles from './ScreenReaderText.module.css';

const screenReaderTextPropsSchema = pipe(
object({
Expand All @@ -37,7 +22,7 @@ type ScreenReaderTextProps = InferInput<typeof screenReaderTextPropsSchema>;
const ScreenReaderText = forwardRef<HTMLDivElement, ScreenReaderTextProps>(function ScreenReaderText(props, ref) {
const { 'aria-hidden': ariaHidden, id, text } = validateProps(screenReaderTextPropsSchema, props);

const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + '';
const classNames = useStyles(styles, webChatPrefixedClassName);

if (ariaHidden && !id) {
console.warn(
Expand All @@ -46,7 +31,7 @@ const ScreenReaderText = forwardRef<HTMLDivElement, ScreenReaderTextProps>(funct
}

return (
<div aria-hidden={ariaHidden} className={rootClassName} id={id} ref={ref}>
<div aria-hidden={ariaHidden} className={classNames['screen-reader-text']} id={id} ref={ref}>
{text}
</div>
);
Expand Down
22 changes: 22 additions & 0 deletions packages/component/src/Utils/FixedWidthImage.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
:global(.webchat) .fixed-width-image {
display: flex;
justify-content: center;
max-height: var(--webchat__max-height--image-bubble);
min-height: var(--webchat__min-height--image-bubble);
overflow: hidden;
position: relative;

.fixed-width-image__filler {
visibility: hidden;
width: 100%;
}

.fixed-width-image__image {
left: 50%;
object-fit: cover;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 100%;
}
}
45 changes: 8 additions & 37 deletions packages/component/src/Utils/FixedWidthImage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot';
import classNames from 'classnames';
import { useStyles, webChatPrefixedClassName } from '@msinternal/botframework-webchat-styles/react';
import cx from 'classnames';
import React, { memo, type SyntheticEvent } from 'react';
import {
args,
Expand All @@ -17,33 +18,7 @@ import {
void_
} from 'valibot';

import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject';
import CSSTokens from '../Styles/CSSTokens';

const ROOT_STYLE = {
'&.webchat__fixed-width-image': {
display: 'flex',
justifyContent: 'center',
maxHeight: CSSTokens.MaxHeightImageBubble,
minHeight: CSSTokens.MinHeightImageBubble,
overflow: 'hidden',
position: 'relative',

'& .webchat__fixed-width-image__filler': {
visibility: 'hidden',
width: '100%'
},

'& .webchat__fixed-width-image__image': {
left: '50%',
objectFit: 'cover',
position: 'absolute',
top: '50%',
transform: 'translate(-50%, -50%)',
width: '100%'
}
}
};
import styles from './FixedWidthImage.module.css';

// React calls event handlers with a `SyntheticEvent`, which is not an `Event`, it wraps one in `nativeEvent`.
// `custom()` passes the event as-is, `object()` would pass a copy with only the validated properties.
Expand Down Expand Up @@ -72,16 +47,12 @@ type FixedWidthImageProps = InferReactProps<typeof fixedWidthImagePropsSchema>;
function FixedWidthImage(props: FixedWidthImageProps) {
const { alt, className, onError, onLoad, src } = validateProps(fixedWidthImagePropsSchema, props);

const classNames = useStyles(styles, webChatPrefixedClassName);

return (
<div
className={classNames(
'webchat__fixed-width-image',
useStyleToEmotionObject()(ROOT_STYLE) + '',
(className || '') + ''
)}
>
<img alt="" className="webchat__fixed-width-image__filler" role="none" src={src} />
<img alt={alt} className="webchat__fixed-width-image__image" onError={onError} onLoad={onLoad} src={src} />
<div className={cx(classNames['fixed-width-image'], className)}>
<img alt="" className={classNames['fixed-width-image__filler']} role="none" src={src} />
<img alt={alt} className={classNames['fixed-width-image__image']} onError={onError} onLoad={onLoad} src={src} />
</div>
);
}
Expand Down
17 changes: 17 additions & 0 deletions packages/component/src/Utils/InlineMarkdown.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
:global(.webchat) .inline-markdown {
button[data-markdown-href] {
appearance: none;
background-color: transparent;
border: 0;
color: var(--webchat__color--accent);
cursor: pointer;
font-family: inherit;
font-size: inherit;
padding: 0;

@media screen and (forced-colors: active) {
color: LinkText;
text-decoration: underline;
}
}
}
35 changes: 4 additions & 31 deletions packages/component/src/Utils/InlineMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
stripParagraphContainer
} from '@msinternal/botframework-webchat-component-better-link';
import { validateProps } from '@msinternal/botframework-webchat-react-valibot';
import { useStyleOptions } from 'botframework-webchat-api/hook.js';
import { useStyles, webChatPrefixedClassName } from '@msinternal/botframework-webchat-styles/react';
import { micromark } from 'micromark';
import React, { useCallback, useMemo } from 'react';
import {
Expand All @@ -26,8 +26,8 @@ import {
type InferInput
} from 'valibot';

import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject';
import createCustomEvent from './createCustomEvent';
import styles from './InlineMarkdown.module.css';

const referenceEventSchema = union([instance(Event), object({ data: string() })]);

Expand All @@ -45,34 +45,7 @@ type InlineMarkdownProps = InferInput<typeof inlineMarkdownPropsSchema>;
const InlineMarkdown = (props: InlineMarkdownProps) => {
const { markdown, onReference, references } = validateProps(inlineMarkdownPropsSchema, props);

const [{ accent }] = useStyleOptions();
const styleToClassName = useStyleToEmotionObject();

// We inlined the style here because this style is:
// 1. Internal to Web Chat
// 2. Not customizable from developers (other than setting `styleOptions.accent`)
const className = useMemo(
() =>
styleToClassName({
'& button[data-markdown-href]': {
appearance: 'none',
backgroundColor: 'transparent',
border: 0,
color: accent,
cursor: 'pointer',
fontFamily: 'inherit',
fontSize: 'inherit',
padding: 0
},
'@media screen and (forced-colors: active)': {
'& button[data-markdown-href]': {
color: 'LinkText',
textDecoration: 'underline'
}
}
}) + '',
[accent, styleToClassName]
);
const classNames = useStyles(styles, webChatPrefixedClassName);

const html = useMemo(() => {
let markdownWithLinkReferenceDefinitions = markdown;
Expand Down Expand Up @@ -114,7 +87,7 @@ const InlineMarkdown = (props: InlineMarkdownProps) => {
[onReference]
);

return <span className={className} dangerouslySetInnerHTML={html} onClick={handleClick} />;
return <span className={classNames['inline-markdown']} dangerouslySetInnerHTML={html} onClick={handleClick} />;
};

export default InlineMarkdown;
Expand Down
Loading