diff --git a/packages/react-chat/src/components/utils/useChatMessageFocusableGroup.ts b/packages/react-chat/src/components/utils/useChatMessageFocusableGroup.ts index e427aa5b..8373a472 100644 --- a/packages/react-chat/src/components/utils/useChatMessageFocusableGroup.ts +++ b/packages/react-chat/src/components/utils/useChatMessageFocusableGroup.ts @@ -1,4 +1,6 @@ -import { useFocusableGroup } from '@fluentui/react-components'; +import * as React from 'react'; +import { useFocusableGroup } from '@fluentui/react-tabster'; +import { Types as TabsterTypes } from 'tabster'; import { ChatMessageState } from '../ChatMessage/ChatMessage.types'; import { ChatMyMessageState } from '../ChatMyMessage/ChatMyMessage.types'; @@ -9,6 +11,61 @@ export const useChatMessageFocusableGroup = ( tabBehavior: 'limited-trap-focus', }); - (state.body as Record)['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)[ + TabsterTypes.TabsterAttributeName + ]; + + // merge default Tabster attributes with consumer's Tabster attributes + const finalTabsterAttributes = useMergedTabsterAttributes( + groupperAttributes, + consumerTabsterAttributesValue + ? { [TabsterTypes.TabsterAttributeName]: consumerTabsterAttributesValue } + : undefined + ); + + (state.body as Record)[ + 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]); }; diff --git a/packages/react-chat/stories/Chat/ChatWithFocusableContent.stories.tsx b/packages/react-chat/stories/Chat/ChatWithFocusableContent.stories.tsx index 6af9a0cb..57c55ca2 100644 --- a/packages/react-chat/stories/Chat/ChatWithFocusableContent.stories.tsx +++ b/packages/react-chat/stories/Chat/ChatWithFocusableContent.stories.tsx @@ -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>((props, ref) => +
+
+
); -const ChatMessageContent: React.FC> = ( - props -) =>
; +interface ReactionsProps { +id: string; +} +const Message1Reactions: React.FC = ({ id }) => { + return ( + + ); +}; +type CustomChatMessageProps = ChatMessageProps & ChatMyMessageProps & { + user?: User; + CustomReactions?: React.FC; + customTimestamp?: string; + customDetails?: string; + children: React.ReactNode; +}; const CustomChatMessage: React.FC = ({ 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(null); + const messageContentRef = React.useRef(null); + const firstButtonInPopoverRef = React.useRef(null); + const isPopoverOpenFromKeyDown = React.useRef(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 ? ( - } - onKeyDown={handleMessageKeyDown} + + + : undefined} + reactions={CustomReactions? : 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} > - {children} - - ) : ( - - {children} - - )} - + {children} + + + + + + + + + + + + + + + + ); }; +interface ChatLinkProps extends React.AnchorHTMLAttributes { + srLabel:string; +children: React.ReactNode; +} + +const ChatLink: React.FC = ({ srLabel, children, ...props } ) => +{children}; + export const ChatWithFocusableContent: React.FC = () => { const user1: User = { name: 'Ashley McCarthy', status: 'available' }; return ( -
+ <>

Chat with focusable content

- +
+ + - - - Hello I am Ashley + + + 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. - + Nice to meet you! - - This is my homepage. Some text goes here to - demonstrate reading of longer runs of texts. Now follows{' '} - another link which is also a dummy link. + + This is my homepage. 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{' '} + another link which is also a dummy link.
+ ); };