Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add toolbars and improvements for screen reader for chat messages #35

Open
wants to merge 35 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
828db6a
Add simple chat
adamsamec Jun 19, 2023
df71381
Add Control + Enter behavior on messageContorl
adamsamec Jun 19, 2023
88a339c
Refactor chat messages
adamsamec Jun 19, 2023
76f14bc
Add ...rest props
adamsamec Jun 20, 2023
5d7077c
Remove unnecessary role
adamsamec Jun 20, 2023
319ca69
Reformat the code
adamsamec Jun 20, 2023
14ab9e7
Move accessible chat to react-chat
adamsamec Jun 20, 2023
abe3df8
Rename the accessible chat prototype
adamsamec Jun 20, 2023
52d080f
Remove unused imports and add aria-readingmode attribute
adamsamec Jun 20, 2023
4462ef7
Merge branch 'main' into accessible-chat
ling1726 Jun 21, 2023
dd64ea3
cleanup fixes
ling1726 Jun 21, 2023
fc51b2b
Replace aria- attribute withwith document role and set tabindex=0
adamsamec Jun 21, 2023
e73f59d
Update tsconfig.base.json
ling1726 Jun 21, 2023
2289301
merge master
ling1726 Jun 22, 2023
a472248
Merge branch 'main' into accessible-chat
adamsamec Jun 29, 2023
cb31f4e
AddChatLink component for aria-label links
adamsamec Jun 29, 2023
cac7751
Merge branch 'main' into accessible-chat
adamsamec Jul 3, 2023
c4ebcff
Add timestamp, details and compose message narration using aria-label…
adamsamec Jul 3, 2023
ab7f050
Add message reactions
adamsamec Jul 3, 2023
3180584
Reaction text change
adamsamec Jul 3, 2023
59e9a0a
Refactor chat message
adamsamec Jul 4, 2023
5245d42
Fix chat message actions entering
adamsamec Jul 4, 2023
afd3e16
Fix Control + Enter message content focusing
adamsamec Jul 4, 2023
7f49e9b
Use refs instead of getElementById
adamsamec Jul 4, 2023
579bd77
Add toolbars into popover surface
adamsamec Jul 4, 2023
ac9232e
Use document role for chat message contents
adamsamec Jul 4, 2023
c5b1171
Merge branch 'main' into accessible-chat
adamsamec Jul 13, 2023
79f2e74
move application role to the top level div
adamsamec Jul 13, 2023
8e4e992
Move h1 heading outside from the application role
adamsamec Jul 13, 2023
059ba30
Make chat message content texts longer
adamsamec Jul 18, 2023
964136a
Move application role closer to document role and use aria-roledescri…
adamsamec Jul 18, 2023
2088511
Move application role back to top level and change message texts"
adamsamec Jul 19, 2023
6d71b5e
Merge branch 'main' into accessible-chat
adamsamec Jul 20, 2023
ceab7a1
Add missing visual link texts
adamsamec Jul 25, 2023
08e804f
Add srLabel prop for ChatLink instead of children.toString() and repl…
adamsamec Aug 28, 2023
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useFocusableGroup } from '@fluentui/react-components';
import * as React from 'react';
import { useFocusableGroup } from '@fluentui/react-tabster';

Check failure on line 2 in packages/react-chat/src/components/utils/useChatMessageFocusableGroup.ts

View workflow job for this annotation

GitHub Actions / main

'@fluentui/react-tabster' should be listed in the project's dependencies. Run 'npm i -S @fluentui/react-tabster' to add it
import { Types as TabsterTypes } from 'tabster';

Check failure on line 3 in packages/react-chat/src/components/utils/useChatMessageFocusableGroup.ts

View workflow job for this annotation

GitHub Actions / main

'tabster' should be listed in the project's dependencies. Run 'npm i -S tabster' to add it
import { ChatMessageState } from '../ChatMessage/ChatMessage.types';
import { ChatMyMessageState } from '../ChatMyMessage/ChatMyMessage.types';

Expand All @@ -9,6 +11,61 @@
tabBehavior: 'limited-trap-focus',
});

(state.body as Record<string, string | undefined>)['data-tabster'] =
groupperAttributes['data-tabster'];
// TODO: type cast here due to state.body not supporting data-xxx type.
// Need typescript 4.4+ (Feature 2759283) and fluent Slot typing update (https://github.com/microsoft/fluentui/issues/23033)
const consumerTabsterAttributesValue = (state.body as Record<string, string>)[
TabsterTypes.TabsterAttributeName
];

// merge default Tabster attributes with consumer's Tabster attributes
const finalTabsterAttributes = useMergedTabsterAttributes(
groupperAttributes,
consumerTabsterAttributesValue
? { [TabsterTypes.TabsterAttributeName]: consumerTabsterAttributesValue }
: undefined
);

(state.body as Record<string, string | undefined>)[
TabsterTypes.TabsterAttributeName
] = finalTabsterAttributes[TabsterTypes.TabsterAttributeName];
};

/**
* Merge two tabster attributes (object of type {"data-tabster": string}) and return the result.
*/
const useMergedTabsterAttributes: (
attributeOne: TabsterTypes.TabsterDOMAttribute,
attributeTwo?: TabsterTypes.TabsterDOMAttribute
) => TabsterTypes.TabsterDOMAttribute = (attributeOne, attributeTwo) => {
const attributeOneValueString =
attributeOne[TabsterTypes.TabsterAttributeName];
const attributeTwoValueString =
attributeTwo?.[TabsterTypes.TabsterAttributeName];

return React.useMemo(() => {
let attributeOneValue = {};
let attributeTwoValue = {};
if (attributeOneValueString) {
try {
attributeOneValue = JSON.parse(attributeOneValueString);
} catch (e) {
attributeOneValue = {};
}
}
if (attributeTwoValueString) {
try {
attributeTwoValue = JSON.parse(attributeTwoValueString);
} catch (e) {
attributeTwoValue = {};
}
}
return {
[TabsterTypes.TabsterAttributeName]: attributeTwoValueString
? JSON.stringify({
...attributeOneValue,
...attributeTwoValue,
})
: attributeOneValueString,
};
}, [attributeOneValueString, attributeTwoValueString]);
};
209 changes: 171 additions & 38 deletions packages/react-chat/stories/Chat/ChatWithFocusableContent.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,210 @@
import * as React from 'react';
import {
Avatar,
useFluent,
Button,
Link,
Popover,
PopoverProps,
PopoverSurface,
PopoverTrigger,
Toolbar,
useId,
PresenceBadgeStatus,
} from '@fluentui/react-components';
import { Chat, ChatMessage, ChatMyMessage } from '@fluentui-contrib/react-chat';
import {
Chat,
ChatMessage,
ChatMessageProps,
ChatMyMessageProps,
ChatMyMessage,
} from '@fluentui-contrib/react-chat';

import {
EmojiSmileSlightRegular,
} from '@fluentui/react-icons';

import { useTabsterAttributes } from '@fluentui/react-tabster';

interface User {
name: string;
status: PresenceBadgeStatus;
}

interface CustomChatMessageProps {
user?: User;
contentId: string;
children: React.ReactNode;
}
const ChatMessageContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>((props, ref) =>
<div>
<div ref={ref} {...props} role="document" tabIndex={0} />
</div>);

const ChatMessageContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = (
props
) => <div {...props} role="document" tabIndex={0} />;
interface ReactionsProps {
id: string;
}
const Message1Reactions: React.FC<ReactionsProps> = ({ id }) => {
return (
<Button
id={id}
icon={{
children: <EmojiSmileSlightRegular fontSize={16} />,
}}
appearance="subtle"
tabIndex={-1}
aria-label="1 Smile reaction."
>
1
</Button>
);
};

type CustomChatMessageProps = ChatMessageProps & ChatMyMessageProps & {
user?: User;
CustomReactions?: React.FC<ReactionsProps>;
customTimestamp?: string;
customDetails?: string;
children: React.ReactNode;
};
const CustomChatMessage: React.FC<CustomChatMessageProps> = ({
user,
contentId,
CustomReactions,
customTimestamp,
customDetails,
children,
...props
}) => {
const { targetDocument } = useFluent();
const handleMessageKeyDown = (event: React.KeyboardEvent) => {
if (event.ctrlKey && event.key === 'Enter') {
targetDocument?.getElementById(contentId)?.focus();
const [popoverOpen, setPopoverOpen] = React.useState(false);

const messageId = useId('message');
const contentId = `${messageId}-content`;
const reactionsId = `${messageId}-reactions`;
const timestampId = `${messageId}-timestamp`;
const detailsId = `${messageId}-details`;
const popoverSurfaceId = `${messageId}-popover-surface`;
const ChatMessageType = user ? ChatMessage : ChatMyMessage;

const messageRef = React.useRef<HTMLDivElement>(null);
const messageContentRef = React.useRef<HTMLDivElement>(null);
const firstButtonInPopoverRef = React.useRef<HTMLButtonElement>(null);
const isPopoverOpenFromKeyDown = React.useRef<boolean>(false);

React.useEffect(() => {
if (popoverOpen && isPopoverOpenFromKeyDown.current) {
isPopoverOpenFromKeyDown.current = false;
firstButtonInPopoverRef.current?.focus();
}
}, [popoverOpen]);

const handlePopoverOpenChange: PopoverProps['onOpenChange'] = (event, { open }) =>
setPopoverOpen(open);

const handleChatMessageKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
if (event.ctrlKey) {
messageContentRef.current?.focus();
// targetDocument?.getElementById(contentId)?.focus();
}else if (event.target === messageRef.current) {
isPopoverOpenFromKeyDown.current = true;
}
}
};

const modalizerAttributes = useTabsterAttributes({
modalizer: {
id: messageId,
isOthersAccessible: true,
isAlwaysAccessible: true,
isTrapped: true,
},
focusable: {
ignoreKeydown: { Enter: true },
},
});

return (
<>
{user ? (
<ChatMessage
avatar={<Avatar name={user.name} badge={{ status: user.status }} />}
onKeyDown={handleMessageKeyDown}
<Popover
openOnHover
open={popoverOpen}
onOpenChange={handlePopoverOpenChange}
unstable_disableAutoFocus // prevent popover focus within popoverSurface on open
>
<PopoverTrigger>
<ChatMessageType
{...modalizerAttributes}
ref={messageRef}

role="group"
avatar={user ? <Avatar name={user.name} badge={{ status: user.status }} /> : undefined}
reactions={CustomReactions? <CustomReactions id={reactionsId} /> : undefined}
timestamp={customTimestamp ? {children: customTimestamp, id: timestampId} : undefined}
details={customDetails? {children: customDetails, id: detailsId} : undefined}
onKeyDown={handleChatMessageKeyDown}
{...(popoverOpen && { 'aria-owns': popoverSurfaceId })}
aria-labelledby={`${contentId} ${reactionsId} ${timestampId} ${detailsId}`}
aria-expanded={undefined}
{...props}
>
<ChatMessageContent id={contentId}>{children}</ChatMessageContent>
</ChatMessage>
) : (
<ChatMyMessage onKeyDown={handleMessageKeyDown}>
<ChatMessageContent id={contentId}>{children}</ChatMessageContent>
</ChatMyMessage>
)}
</>
<ChatMessageContent ref={messageContentRef} id={contentId}>{children}</ChatMessageContent>
</ChatMessageType>
</PopoverTrigger>
<PopoverSurface
{...modalizerAttributes}
id={popoverSurfaceId}
>
<Toolbar>
<Button ref={firstButtonInPopoverRef}>Like</Button>
<Button>Heart</Button>
<Button>Laugh</Button>
<Button>Surprised</Button>
<Button aria-expanded="false">More reactions</Button>
</Toolbar>
<Toolbar>
<Button>Reply</Button>
<Button>More options...</Button>
</Toolbar>
</PopoverSurface>
</Popover>
);
};

interface ChatLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
srLabel:string;
children: React.ReactNode;
}

const ChatLink: React.FC<ChatLinkProps> = ({ srLabel, children, ...props } ) =>
<Link {...props} aria-label={`Link ${srLabel}`}>{children}</Link>;

export const ChatWithFocusableContent: React.FC = () => {
const user1: User = { name: 'Ashley McCarthy', status: 'available' };

return (
<div>
<>
<h1>Chat with focusable content</h1>
<button> start here</button>
<div role="application">

<button>Before chat</button>

<Chat role="application">
<CustomChatMessage user={user1} contentId="message1-content">
Hello I am Ashley
<Chat>
<CustomChatMessage
user={user1}
CustomReactions={Message1Reactions}
customTimestamp="June 20, 2023 9:35 AM."
>
Hello I am Ashley. This is an examplary long message content which we would like to read in the document screen reader mode. NVDA already implements sufficient support for the automatic switching of the screen reader mode when an element with the "document" role or its descendant is focused or when explicitly enabled by the user, and when it is contained within an element with the "application" role. However, JAWS does not yet behave as we would expect to, therefore, we hope to achieve the desired behavior also with JAWS. Once implemented, this will significantly ease the reading of long messages or even enable convenient text selection. This will also solve the issue with JAWS which trims long messages to a certain character limit.
</CustomChatMessage>
<CustomChatMessage contentId="message2-content">
<CustomChatMessage
customTimestamp="Today at 3:10 PM."
customDetails="Edited"
>
Nice to meet you!
</CustomChatMessage>
<CustomChatMessage user={user1} contentId="message3-content">
This is <a href="#">my homepage</a>. Some text goes here to
demonstrate reading of longer runs of texts. Now follows{' '}
<a href="#">another link</a> which is also a dummy link.
<CustomChatMessage
user={user1}
customTimestamp="Today at 5:22 PM."
>
This is <ChatLink href="https://www.microsoft.com" srLabel="my homepage">my homepage</ChatLink>. Some text goes here to
further demonstrate reading of longer runs of texts. To make an example of another interactive element within a message, now follows{' '}
<ChatLink href="#" srLabel="another link">another link</ChatLink> which is also a dummy link.
</CustomChatMessage>
</Chat>
</div>
</>
);
};
Loading