diff --git a/.changelog/5966-fix-logical-group-key-hook.md b/.changelog/5966-fix-logical-group-key-hook.md new file mode 100644 index 0000000000..767e71d43a --- /dev/null +++ b/.changelog/5966-fix-logical-group-key-hook.md @@ -0,0 +1,3 @@ +### Fixed + +- Fixed grouped messages showing avatars and breaking keyboard navigation in React Strict Mode, in PR [#5966](https://github.com/microsoft/BotFramework-WebChat/pull/5966), by [@OEvgeny](https://github.com/OEvgeny) diff --git a/__tests__/html2/part-grouping/strictMode.html b/__tests__/html2/part-grouping/strictMode.html new file mode 100644 index 0000000000..634034cd30 --- /dev/null +++ b/__tests__/html2/part-grouping/strictMode.html @@ -0,0 +1,116 @@ + + + + Part grouping: keeps the group after Strict Mode re-runs effects + + + + + + + + +
+ + + diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-1.png b/__tests__/html2/part-grouping/strictMode.html.snap-1.png new file mode 100644 index 0000000000..f9949778b1 Binary files /dev/null and b/__tests__/html2/part-grouping/strictMode.html.snap-1.png differ diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-2.png b/__tests__/html2/part-grouping/strictMode.html.snap-2.png new file mode 100644 index 0000000000..39bfe37ad3 Binary files /dev/null and b/__tests__/html2/part-grouping/strictMode.html.snap-2.png differ diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-3.png b/__tests__/html2/part-grouping/strictMode.html.snap-3.png new file mode 100644 index 0000000000..adbc85e88d Binary files /dev/null and b/__tests__/html2/part-grouping/strictMode.html.snap-3.png differ diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-4.png b/__tests__/html2/part-grouping/strictMode.html.snap-4.png new file mode 100644 index 0000000000..a9523451b6 Binary files /dev/null and b/__tests__/html2/part-grouping/strictMode.html.snap-4.png differ diff --git a/packages/component/src/Activity/StackedLayout.tsx b/packages/component/src/Activity/StackedLayout.tsx index 377fcea483..8d9eb45cc1 100644 --- a/packages/component/src/Activity/StackedLayout.tsx +++ b/packages/component/src/Activity/StackedLayout.tsx @@ -21,7 +21,7 @@ import getFirstBaseOfSoftwareSourceCode from '../Utils/orgSchema/getFirstBaseOfS import textFormatToContentType from '../Utils/textFormatToContentType'; import useLocalizeActivityGreetingAlt from '../hooks/internal/useLocalizeActivityGreetingAlt.js'; import useLocalizeProgrammingLanguage from '../hooks/internal/useLocalizeProgrammingLanguage'; -import { useGetLogicalGroupKey } from '../providers/ActivityLogicalGrouping'; +import { useLogicalGroupKey } from '../providers/ActivityLogicalGrouping'; import AttachmentRow from './AttachmentRow'; import Bubble from './Bubble'; import CodeBlockContent from './CodeBlockContent'; @@ -144,8 +144,8 @@ const StackedLayout = ({ const nubSize = fromUser ? bubbleFromUserNubSize : bubbleNubSize; const otherInitials = fromUser ? botInitials : userInitials; const otherNubSize = fromUser ? bubbleNubSize : bubbleFromUserNubSize; - const activityKey = useGetKeyByActivity()(activity); - const isInGroup = !!useGetLogicalGroupKey()(activityKey); + const [logicalGroupKey] = useLogicalGroupKey(useGetKeyByActivity()(activity)); + const isInGroup = !!logicalGroupKey; const hasAvatar = (initials || typeof initials === 'string') && !isInGroup; const hasOtherAvatar = otherInitials || typeof otherInitials === 'string'; diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx index 4b221a11c4..f0fde958d4 100644 --- a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx +++ b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx @@ -13,7 +13,7 @@ import StackedLayoutStatus from '../../../../../Activity/StackedLayoutStatus'; import useActivityElementMapRef from '../../../../../providers/ChatHistoryDOM/useActivityElementRef'; import useActiveDescendantId from '../../../../../providers/TranscriptFocus/useActiveDescendantId'; import useFocusByGroupKey from '../../../../../providers/TranscriptFocus/useFocusByGroupKey'; -import useGetGroupDescendantIdByActivityKey from '../../../../../providers/TranscriptFocus/useGetGroupDescendantIdByActivityKey'; +import useGroupDescendantIdByGroupKey from '../../../../../providers/TranscriptFocus/useGroupDescendantIdByGroupKey'; import FocusTrap from '../../../../../Transcript/FocusTrap'; import useRenderActivityProps from '../../../../../Transcript/hooks/useRenderActivityProps'; import { @@ -50,7 +50,6 @@ type PartGroupingActivityProps = InferOutput(value => is(object({}), value)), children: optional(reactNode()), className: optional(string()), groupKey: string() @@ -64,15 +63,12 @@ type FocusablePartGroupingActivityProps = InferOutput void>( (withFocus?: boolean) => focusByGroupKey(groupKey, withFocus), @@ -220,11 +216,7 @@ function PartGroupingActivity(props: PartGroupingActivityProps) { ); return ( - + `part-${random().toString(36).slice(2, 11)}`); - const addLogicalGrouping = useAddLogicalGrouping(); + const partGroupKey = useMemo(() => `part-${random().toString(36).slice(2, 11)}`, []); const removeLogicalGrouping = useRemoveLogicalGrouping(); + const setLogicalGrouping = useSetLogicalGrouping(); - const isCollapsedRef = useRefFrom(isCollapsed); + // In layout effects, not while rendering: setting re-renders the activities before paint. + // Strict Mode runs the cleanup below, then this effect again. + // Removed in its own effect, so a change of `activityKeys` updates the group instead of removing and adding it. + useLayoutEffect(() => { + setLogicalGrouping(partGroupKey, { activityKeys: Object.freeze(Array.from(activityKeys)), isCollapsed }); + }, [activityKeys, isCollapsed, partGroupKey, setLogicalGrouping]); - const getGroupState = useCallback(() => ({ isCollapsed: isCollapsedRef.current }), [isCollapsedRef]); - - useMemo( - () => - addLogicalGrouping({ - key: partKeyRef.current, - activityKeys: Array.from(activityKeys), - getGroupState - }), - [activityKeys, addLogicalGrouping, getGroupState, partKeyRef] - ); - - const removeLogicalGroupingRef = useRefFrom(removeLogicalGrouping); - - useEffect(() => () => removeLogicalGroupingRef.current(partKeyRef.current), [partKeyRef, removeLogicalGroupingRef]); + useLayoutEffect(() => () => removeLogicalGrouping(partGroupKey), [partGroupKey, removeLogicalGrouping]); return partGroupKey; } diff --git a/packages/component/src/hooks/internal/createKeyedStore.ts b/packages/component/src/hooks/internal/createKeyedStore.ts new file mode 100644 index 0000000000..5a2454d4c0 --- /dev/null +++ b/packages/component/src/hooks/internal/createKeyedStore.ts @@ -0,0 +1,38 @@ +type ReadonlyKeyedStore = { + readonly get: (key: string) => T | undefined; + /** Calls the listener after the value of the key changed, until unsubscribed. */ + readonly subscribe: (key: string, listener: () => void) => () => void; +}; + +type KeyedStore = ReadonlyKeyedStore & { + readonly delete: (key: string) => void; + /** Notifies the listeners of the key if the value changed. Listeners may re-render: call it after commit, e.g. from an effect. */ + readonly set: (key: string, value: T | undefined) => void; +}; + +/** Creates a store of values by key, which notifies only the listeners of keys whose value changed. */ +export default function createKeyedStore(): KeyedStore { + const eventTarget = new EventTarget(); + const values = new Map(); + + const set = (key: string, value: T | undefined): void => { + if (!Object.is(values.get(key), value)) { + typeof value === 'undefined' ? values.delete(key) : values.set(key, value); + + eventTarget.dispatchEvent(new Event(key)); + } + }; + + return Object.freeze({ + delete: (key: string) => set(key, undefined), + get: (key: string) => values.get(key), + set, + subscribe: (key: string, listener: () => void) => { + eventTarget.addEventListener(key, listener); + + return () => eventTarget.removeEventListener(key, listener); + } + }); +} + +export { type KeyedStore, type ReadonlyKeyedStore }; diff --git a/packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx b/packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx new file mode 100644 index 0000000000..dbf274cd56 --- /dev/null +++ b/packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx @@ -0,0 +1,163 @@ +/** @jest-environment @happy-dom/jest-environment */ + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React, { Fragment, memo, useLayoutEffect } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import createKeyedStore, { type KeyedStore } from './createKeyedStore'; +import useKeyedStoreValue from './useKeyedStoreValue'; + +type Renders = { readonly a: (string | undefined)[]; readonly b: (string | undefined)[] }; + +function Reader({ + renders, + store, + valueKey +}: { + readonly renders: Renders; + readonly store: KeyedStore; + readonly valueKey: 'a' | 'b'; +}) { + // eslint-disable-next-line security/detect-object-injection + renders[valueKey].push(useKeyedStoreValue(store, valueKey)[0]); + + return null; +} + +// Memoized like activities in the transcript, so only its subscription can re-render it. +const MemoizedReader = memo(Reader); + +// Placed before ``: its layout effect runs after `` rendered and before `` subscribes. +function SetOnMount({ + store, + value, + valueKey +}: { + readonly store: KeyedStore; + readonly value: string; + readonly valueKey: string; +}) { + useLayoutEffect(() => store.set(valueKey, value), [store, value, valueKey]); + + return null; +} + +scenario( + 'useKeyedStoreValue', + bdd => { + bdd + .given('readers of "a" and "b"', () => { + const container = document.createElement('div'); + const renders: Renders = { a: [], b: [] }; + const store = createKeyedStore(); + + act(() => { + render( + + + + , + container + ); + }); + + return { container, renders, store }; + }) + .when('"a" is set', ({ store }) => { + act(() => store.set('a', '1')); + }) + .then('only the reader of "a" should re-render', ({ renders }) => { + expect(renders.a).toStrictEqual([undefined, '1']); + expect(renders.b).toStrictEqual([undefined]); + }) + .when('"a" is set to the same value', ({ store }) => { + act(() => store.set('a', '1')); + }) + .then('the reader of "a" should not re-render', ({ renders }) => + expect(renders.a).toStrictEqual([undefined, '1']) + ) + .when('"a" is deleted', ({ store }) => { + act(() => store.delete('a')); + }) + .then('the reader of "a" should re-render without a value', ({ renders }) => + expect(renders.a).toStrictEqual([undefined, '1', undefined]) + ) + .when('the readers unmount and "a" is set', ({ container, store }) => { + act(() => { + unmountComponentAtNode(container); + }); + + act(() => store.set('a', '2')); + }) + .then('the reader of "a" should not render again, it unsubscribed', ({ renders }) => + expect(renders.a).toStrictEqual([undefined, '1', undefined]) + ); + + bdd + .given('a store', () => ({ renders: { a: [], b: [] } as Renders, store: createKeyedStore() })) + .when('"a" is set after the reader rendered, before it subscribed', ({ renders, store }) => { + act(() => { + render( + + + + , + document.createElement('div') + ); + }); + }) + .then('the reader should re-render with the missed value', ({ renders }) => + expect(renders.a).toStrictEqual([undefined, 'missed']) + ); + + bdd + .given('a reader of "a", "a" is "1" and "b" is "2"', () => { + const container = document.createElement('div'); + const renders: Renders = { a: [], b: [] }; + const store = createKeyedStore(); + + store.set('a', '1'); + store.set('b', '2'); + + // `{false}` keeps the reader at the same position as in the next render, so it updates instead of remounting. + act(() => { + render( + + {false} + + , + container + ); + }); + + return { container, renders, store }; + }) + .when( + 'the reader switches to "b" as "b" changes to "1" before it subscribes', + ({ container, renders, store }) => { + act(() => { + render( + + + + , + container + ); + }); + } + ) + .then('the reader should re-render with "1", the new value of "b"', ({ renders }) => + // Rendered "2" first, then "1" after the re-check, although "1" is also the value it rendered for "a". + expect(renders.b).toStrictEqual(['2', '1']) + ) + .when('"a" is set', ({ store }) => { + act(() => store.set('a', '3')); + }) + .then('the reader should not re-render, it unsubscribed from "a"', ({ renders }) => + expect(renders.b).toStrictEqual(['2', '1']) + ); + }, + JestGlobals +); diff --git a/packages/component/src/hooks/internal/useKeyedStoreValue.ts b/packages/component/src/hooks/internal/useKeyedStoreValue.ts new file mode 100644 index 0000000000..4a2d40fd55 --- /dev/null +++ b/packages/component/src/hooks/internal/useKeyedStoreValue.ts @@ -0,0 +1,33 @@ +import { useLayoutEffect, useMemo, useState } from 'react'; + +import { type ReadonlyKeyedStore } from './createKeyedStore'; + +/** + * Returns the value of the key in the store, and re-renders when it changes. + * + * When React 18 is the minimum, replace the body with + * `useSyncExternalStore(useCallback(listener => store.subscribe(key, listener), [key, store]), () => store.get(key))`. + */ +export default function useKeyedStoreValue(store: ReadonlyKeyedStore, key: string): readonly [T | undefined] { + const value = store.get(key); + + // The state only triggers re-renders. It holds the key: after a key switch, the re-check below always re-renders, + // even if the value of the new key has just changed to the value rendered for the previous key. + const [, setState] = useState(() => Object.freeze([key, value])); + + useLayoutEffect(() => { + const handleChange = () => + setState(state => { + const nextValue = store.get(key); + + return state[0] === key && Object.is(state[1], nextValue) ? state : Object.freeze([key, nextValue]); + }); + + // Catches changes between render and subscription. + handleChange(); + + return store.subscribe(key, handleChange); + }, [key, store]); + + return useMemo(() => Object.freeze([value] as const), [value]); +} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts index 2fdb1b78c8..c07b513975 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts @@ -1,10 +1,10 @@ import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import { createElement, memo, useCallback, useMemo, useRef } from 'react'; +import { createElement, memo, useCallback, useMemo } from 'react'; import { object, optional, pipe, readonly, type InferInput } from 'valibot'; +import createKeyedStore from '../../hooks/internal/createKeyedStore'; import ActivityLogicalGroupingContext, { type ActivityLogicalGroupingContextType, - type GroupState, type LogicalGrouping } from './private/ActivityLogicalGroupingContext'; @@ -20,71 +20,43 @@ type ActivityLogicalGroupingComposerProps = InferInput { const { children } = validateProps(activityLogicalGroupingComposerPropsSchema, props); - const logicalGroupingsRef = useRef>(new Map()); - // A ref, not a state: part grouping adds itself while rendering, and its activities read their group while rendering. - const activityToGroupMapRef = useRef>(new Map()); + // Both stores notify only the keys whose value changed. + const logicalGroupingStore = useMemo(() => createKeyedStore(), []); + const logicalGroupKeyStore = useMemo(() => createKeyedStore(), []); - const addLogicalGrouping = useCallback( - (grouping: LogicalGrouping) => { - const prevGroupingKeys = logicalGroupingsRef.current.get(grouping.key)?.activityKeys ?? []; - - logicalGroupingsRef.current.set(grouping.key, grouping); - - const toRemoveSet = new Set(prevGroupingKeys).difference(new Set(grouping.activityKeys)); - const toAddSet = new Set(grouping.activityKeys).difference(new Set(prevGroupingKeys)); + // Removes the activity from the group, unless it has already moved to another group. + const leaveLogicalGrouping = useCallback( + (activityKey: string, key: string) => + logicalGroupKeyStore.get(activityKey) === key && logicalGroupKeyStore.delete(activityKey), + [logicalGroupKeyStore] + ); - activityToGroupMapRef.current = new Map( - [].concat( - Array.from(activityToGroupMapRef.current).filter(([, value]) => !!value && !toRemoveSet.has(value)), - Array.from(toAddSet).map(activityKey => [activityKey, grouping.key] as const) - ) - ); + // Setting and removing re-render the activities that subscribed: call them from effects, never while rendering. + const setLogicalGrouping = useCallback( + (key: string, grouping: LogicalGrouping) => { + logicalGroupingStore + .get(key) + ?.activityKeys.forEach( + activityKey => !grouping.activityKeys.includes(activityKey) && leaveLogicalGrouping(activityKey, key) + ); + + logicalGroupingStore.set(key, grouping); + grouping.activityKeys.forEach(activityKey => logicalGroupKeyStore.set(activityKey, key)); }, - [activityToGroupMapRef] + [leaveLogicalGrouping, logicalGroupKeyStore, logicalGroupingStore] ); const removeLogicalGrouping = useCallback( (key: string) => { - logicalGroupingsRef.current.delete(key); - activityToGroupMapRef.current = new Map([...activityToGroupMapRef.current].filter(([, value]) => value !== key)); - }, - [activityToGroupMapRef] - ); - - const getLogicalGroupKey = useCallback( - (activityKey: string): string | undefined => activityToGroupMapRef.current.get(activityKey), - [activityToGroupMapRef] - ); - - const getGroupState = useCallback( - (groupKey: string): GroupState | undefined => { - const group = logicalGroupingsRef.current.get(groupKey); - return group?.getGroupState?.(); - }, - [logicalGroupingsRef] - ); - - const getGroupBoundaries = useCallback( - (groupKey: string): [string, string] => { - const group = logicalGroupingsRef.current.get(groupKey); - if (!group || !group.activityKeys.length) { - return [undefined, undefined]; - } - // eslint-disable-next-line no-magic-numbers - return [group.activityKeys.at(0), group.activityKeys.at(-1)]; + logicalGroupingStore.get(key)?.activityKeys.forEach(activityKey => leaveLogicalGrouping(activityKey, key)); + logicalGroupingStore.delete(key); }, - [logicalGroupingsRef] + [leaveLogicalGrouping, logicalGroupingStore] ); - const contextValue: ActivityLogicalGroupingContextType = useMemo( - () => ({ - addLogicalGrouping, - getLogicalGroupKey, - getGroupState, - getGroupBoundaries, - removeLogicalGrouping - }), - [addLogicalGrouping, getLogicalGroupKey, getGroupState, getGroupBoundaries, removeLogicalGrouping] + const contextValue = useMemo( + () => Object.freeze({ logicalGroupKeyStore, logicalGroupingStore, removeLogicalGrouping, setLogicalGrouping }), + [logicalGroupKeyStore, logicalGroupingStore, removeLogicalGrouping, setLogicalGrouping] ); return createElement(ActivityLogicalGroupingContext.Provider, { value: contextValue }, children); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/index.ts b/packages/component/src/providers/ActivityLogicalGrouping/index.ts index 50b2650c78..bb85553bf6 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/index.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/index.ts @@ -1,7 +1,6 @@ export { default as ActivityLogicalGroupingComposer } from './ActivityLogicalGroupingComposer'; -export { default as useAddLogicalGrouping } from './useAddLogicalGrouping'; +export { default as useLogicalGroupKey } from './useLogicalGroupKey'; export { default as useRemoveLogicalGrouping } from './useRemoveLogicalGrouping'; -export { default as useGetLogicalGroupKey } from './useGetLogicalGroupKey'; -export { default as useGetGroupState } from './useGetGroupState'; +export { default as useSetLogicalGrouping } from './useSetLogicalGrouping'; export type { ActivityLogicalGroupingComposerProps } from './ActivityLogicalGroupingComposer'; -export type { LogicalGrouping, GroupState } from './private/ActivityLogicalGroupingContext'; +export type { LogicalGrouping } from './private/ActivityLogicalGroupingContext'; diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts index e0f5397b41..cb8b253384 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts @@ -1,22 +1,20 @@ +import { type ReadonlyKeyedStore } from '../../../hooks/internal/createKeyedStore'; import createContextAndHook from '../../createContextAndHook'; -type GroupState = Readonly<{ - isCollapsed: boolean; -}>; - -type LogicalGrouping = Readonly<{ - activityKeys: string[]; - getGroupState?: () => GroupState; - key: string; -}>; +type LogicalGrouping = { + readonly activityKeys: readonly string[]; + readonly isCollapsed: boolean; +}; -type ActivityLogicalGroupingContextType = Readonly<{ - addLogicalGrouping: (grouping: LogicalGrouping) => void; - getGroupBoundaries: (groupKey: string) => [string | undefined, string | undefined]; - getGroupState: (groupKey: string) => GroupState | undefined; - getLogicalGroupKey: (activityKey: string) => string | undefined; - removeLogicalGrouping: (key: string) => void; -}>; +type ActivityLogicalGroupingContextType = { + /** Group key by activity key. Read with `useLogicalGroupKey()` while rendering, or `get()` at event time. */ + readonly logicalGroupKeyStore: ReadonlyKeyedStore; + /** Logical grouping by group key. */ + readonly logicalGroupingStore: ReadonlyKeyedStore; + readonly removeLogicalGrouping: (key: string) => void; + /** Adds the logical grouping, or replaces the one with the same key. */ + readonly setLogicalGrouping: (key: string, grouping: LogicalGrouping) => void; +}; const { contextComponentType, useContext } = createContextAndHook( 'ActivityLogicalGroupingContext' @@ -26,6 +24,5 @@ export default contextComponentType; export { useContext as useActivityLogicalGroupingContext, type ActivityLogicalGroupingContextType, - type GroupState, type LogicalGrouping }; diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/useLogicalGroupingStores.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/useLogicalGroupingStores.ts new file mode 100644 index 0000000000..13e5ceeafe --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/useLogicalGroupingStores.ts @@ -0,0 +1,15 @@ +import { + useActivityLogicalGroupingContext, + type ActivityLogicalGroupingContextType +} from './ActivityLogicalGroupingContext'; + +/** + * For keyboard navigation, which reads the groups of other activities at event time. + * To render from a group key, use `useLogicalGroupKey()`, which subscribes. + */ +export default function useLogicalGroupingStores(): Pick< + ActivityLogicalGroupingContextType, + 'logicalGroupKeyStore' | 'logicalGroupingStore' +> { + return useActivityLogicalGroupingContext(); +} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useAddLogicalGrouping.ts b/packages/component/src/providers/ActivityLogicalGrouping/useAddLogicalGrouping.ts deleted file mode 100644 index 0fad15042d..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/useAddLogicalGrouping.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useActivityLogicalGroupingContext, type LogicalGrouping } from './private/ActivityLogicalGroupingContext'; - -export default function useAddLogicalGrouping(): (grouping: LogicalGrouping) => void { - return useActivityLogicalGroupingContext().addLogicalGrouping; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGetGroupState.ts b/packages/component/src/providers/ActivityLogicalGrouping/useGetGroupState.ts deleted file mode 100644 index 1fdbaa3acf..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/useGetGroupState.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useActivityLogicalGroupingContext, type GroupState } from './private/ActivityLogicalGroupingContext'; - -export default function useGetGroupState(): (groupKey: string) => GroupState | undefined { - return useActivityLogicalGroupingContext().getGroupState; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupBoundaries.ts b/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupBoundaries.ts deleted file mode 100644 index bb98b739c4..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupBoundaries.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useActivityLogicalGroupingContext } from './private/ActivityLogicalGroupingContext'; - -export default function useGetLogicalGroupBoundaries(): (groupKey: string) => [string | undefined, string | undefined] { - return useActivityLogicalGroupingContext().getGroupBoundaries; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupKey.ts b/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupKey.ts deleted file mode 100644 index bde0df9be9..0000000000 --- a/packages/component/src/providers/ActivityLogicalGrouping/useGetLogicalGroupKey.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useActivityLogicalGroupingContext } from './private/ActivityLogicalGroupingContext'; - -export default function useGetLogicalGroupKey(): (activityKey: string) => string | undefined { - return useActivityLogicalGroupingContext().getLogicalGroupKey; -} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx new file mode 100644 index 0000000000..e925cfe22e --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.spec.tsx @@ -0,0 +1,94 @@ +/** @jest-environment @happy-dom/jest-environment */ + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React, { memo, useLayoutEffect } from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import ActivityLogicalGroupingComposer from './ActivityLogicalGroupingComposer'; +import useLogicalGroupKey from './useLogicalGroupKey'; +import useRemoveLogicalGrouping from './useRemoveLogicalGrouping'; +import useSetLogicalGrouping from './useSetLogicalGrouping'; + +type Renders = { readonly a: (string | undefined)[]; readonly b: (string | undefined)[] }; + +// Registers like part grouping: set in a layout effect, removed on unmount. +function Group({ activityKeys, groupKey }: { readonly activityKeys: readonly string[]; readonly groupKey: string }) { + const removeLogicalGrouping = useRemoveLogicalGrouping(); + const setLogicalGrouping = useSetLogicalGrouping(); + + useLayoutEffect(() => { + setLogicalGrouping(groupKey, { activityKeys, isCollapsed: false }); + }, [activityKeys, groupKey, setLogicalGrouping]); + + useLayoutEffect(() => () => removeLogicalGrouping(groupKey), [groupKey, removeLogicalGrouping]); + + return null; +} + +function Row({ activityKey, renders }: { readonly activityKey: 'a' | 'b'; readonly renders: Renders }) { + // eslint-disable-next-line security/detect-object-injection + renders[activityKey].push(useLogicalGroupKey(activityKey)[0]); + + return null; +} + +// Memoized like activities in the transcript, so only its subscription can re-render it. +const MemoizedRow = memo(Row); + +// Groups render in the order given, each group key mapped to its activity keys. +function renderGroups(container: HTMLElement, groups: Record, renders: Renders) { + renders.a.splice(0); + renders.b.splice(0); + + act(() => { + render( + + + + {Object.entries(groups).map(([groupKey, activityKeys]) => ( + + ))} + , + container + ); + }); +} + +scenario( + 'useLogicalGroupKey', + bdd => { + bdd + .given('an empty transcript', () => ({ container: document.createElement('div'), renders: { a: [], b: [] } })) + .when('activities "a" and "b" are added to group "g"', ({ container, renders }) => + renderGroups(container, { g: ['a', 'b'] }, renders) + ) + .then('both activities should be in group "g"', ({ renders }) => { + // Rendered before the group is set in a layout effect, then re-rendered with the group before paint. + expect(renders.a).toStrictEqual([undefined, 'g']); + expect(renders.b).toStrictEqual([undefined, 'g']); + }) + .when('activity "b" leaves the group', ({ container, renders }) => renderGroups(container, { g: ['a'] }, renders)) + .then('only activity "b" should re-render, without a group', ({ renders }) => { + expect(renders.a).toStrictEqual([]); + expect(renders.b).toStrictEqual([undefined]); + }) + .when('activity "a" moves to group "h", which is set before group "g"', ({ container, renders }) => + renderGroups(container, { h: ['a'], g: [] }, renders) + ) + .then('only activity "a" should re-render once, in group "h"', ({ renders }) => { + expect(renders.a).toStrictEqual(['h']); + expect(renders.b).toStrictEqual([]); + }) + .when('group "g" is removed', ({ container, renders }) => renderGroups(container, { h: ['a'] }, renders)) + .then('activity "a" should not re-render, it stays in group "h"', ({ renders }) => + expect(renders.a).toStrictEqual([]) + ) + .when('group "h" is removed', ({ container, renders }) => renderGroups(container, {}, renders)) + .then('activity "a" should re-render once, without a group', ({ renders }) => + expect(renders.a).toStrictEqual([undefined]) + ); + }, + JestGlobals +); diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts new file mode 100644 index 0000000000..a6c4d28d76 --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts @@ -0,0 +1,7 @@ +import useKeyedStoreValue from '../../hooks/internal/useKeyedStoreValue'; +import { useActivityLogicalGroupingContext } from './private/ActivityLogicalGroupingContext'; + +/** Returns the key of the logical group of the activity, or `undefined`. Re-renders only when it changes. */ +export default function useLogicalGroupKey(activityKey: string): readonly [string | undefined] { + return useKeyedStoreValue(useActivityLogicalGroupingContext().logicalGroupKeyStore, activityKey); +} diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useSetLogicalGrouping.ts b/packages/component/src/providers/ActivityLogicalGrouping/useSetLogicalGrouping.ts new file mode 100644 index 0000000000..b54cfd4140 --- /dev/null +++ b/packages/component/src/providers/ActivityLogicalGrouping/useSetLogicalGrouping.ts @@ -0,0 +1,5 @@ +import { useActivityLogicalGroupingContext, type LogicalGrouping } from './private/ActivityLogicalGroupingContext'; + +export default function useSetLogicalGrouping(): (key: string, grouping: LogicalGrouping) => void { + return useActivityLogicalGroupingContext().setLogicalGrouping; +} diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupIsCollapsed.spec.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupIsCollapsed.spec.tsx new file mode 100644 index 0000000000..47cb3b7614 --- /dev/null +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.groupIsCollapsed.spec.tsx @@ -0,0 +1,92 @@ +/** @jest-environment @happy-dom/jest-environment */ +/* eslint no-magic-numbers: "off" */ + +import * as JestGlobals from '@jest/globals'; +import { scenario } from '@testduet/given-when-then'; +import React, { useContext } from 'react'; +import { render } from 'react-dom'; +import { act } from 'react-dom/test-utils'; + +import usePartGroupingLogicalGroup from '../../Middleware/ActivityGrouping/ui/PartGrouping/private/usePartGroupingLogicalGroup'; +import { ActivityLogicalGroupingComposer } from '../ActivityLogicalGrouping'; +import useRenderingActivityKeys from '../RenderingActivities/useRenderingActivityKeys'; +import TranscriptFocusContext, { type TranscriptFocusContextType } from './private/Context'; +import TranscriptFocusComposer from './TranscriptFocusComposer'; + +jest.mock('../../Utils/scrollIntoViewWithBlockNearest', () => jest.fn()); +jest.mock('../RenderingActivities/useRenderingActivityKeys'); + +// Every render pushes its values, the last one is current. +type Result = { readonly contexts: TranscriptFocusContextType[]; readonly groupKeys: string[] }; + +// Activity "c" follows the part group with "a" and "b". +const ACTIVITY_KEYS = Object.freeze(['a', 'b', 'c']); +const GROUP_ACTIVITY_KEYS = Object.freeze(['a', 'b']); + +function PartGroup({ isCollapsed, result }: { readonly isCollapsed: boolean; readonly result: Result }) { + result.groupKeys.push(usePartGroupingLogicalGroup({ activityKeys: GROUP_ACTIVITY_KEYS, isCollapsed })); + + return null; +} + +function ContextReader({ result }: { readonly result: Result }) { + result.contexts.push(useContext(TranscriptFocusContext)); + + return null; +} + +function renderTranscript(container: HTMLElement, isCollapsed: boolean, result: Result) { + act(() => { + render( + + + + + + , + container + ); + }); +} + +function getFocusedId({ contexts }: Result): string | undefined { + const context = contexts.at(-1); + + return (context?.activeGroupDescendantIdState[0] || context?.activeDescendantIdState[0])?.replace( + /^webchat__transcript-focus-[^_]+__/u, + '' + ); +} + +scenario( + 'TranscriptFocusComposer reads whether the group is collapsed while handling a key press', + bdd => { + bdd + .given('a transcript with an expanded part group followed by activity "c"', () => { + jest.mocked(useRenderingActivityKeys).mockReturnValue(Object.freeze([ACTIVITY_KEYS])); + + const container = document.createElement('div'); + const result: Result = { contexts: [], groupKeys: [] }; + + renderTranscript(container, false, result); + + return { container, result }; + }) + .when('pressing up from activity "c"', ({ result }) => { + act(() => result.contexts.at(-1)?.focusRelativeActivity(-1)); + }) + .then('activity "b" should be focused, inside the expanded group', ({ result }) => + expect(getFocusedId(result)).toBe('activity-b') + ) + .when('the group collapses and pressing up from activity "c" again', ({ container, result }) => { + renderTranscript(container, true, result); + + act(() => result.contexts.at(-1)?.focusByActivityKey('c', false)); + act(() => result.contexts.at(-1)?.focusRelativeActivity(-1)); + }) + .then('the collapsed group should be focused', ({ result }) => + expect(getFocusedId(result)).toBe(`group-${result.groupKeys.at(-1)}`) + ); + }, + JestGlobals +); diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx index 83ed785169..ab25d027b4 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.spec.tsx @@ -10,7 +10,7 @@ import { act } from 'react-dom/test-utils'; import TranscriptFocusComposer from './TranscriptFocusComposer'; import TranscriptFocusContext, { TranscriptFocusContextType } from './private/Context'; -import { ActivityLogicalGroupingComposer, useAddLogicalGrouping } from '../ActivityLogicalGrouping'; +import { ActivityLogicalGroupingComposer, useSetLogicalGrouping } from '../ActivityLogicalGrouping'; // Mock dependencies jest.mock('../../Utils/scrollIntoViewWithBlockNearest', () => jest.fn()); @@ -55,7 +55,7 @@ describe('TranscriptFocusComposer - focusRelativeActivity', () => { const TestComponent = () => { const context = useContext(TranscriptFocusContext); - const addLogicalGrouping = useAddLogicalGrouping(); + const setLogicalGrouping = useSetLogicalGrouping(); useMemo(() => { Object.assign(result.context, context); @@ -70,19 +70,18 @@ describe('TranscriptFocusComposer - focusRelativeActivity', () => { const groups = new Map(); for (const [activityKey, groupKey] of Object.entries(config.groupKeyMappings)) { const group = groups.get(groupKey) ?? { - key: groupKey, activityKeys: [], - getGroupState: () => config.groupStates[groupKey] + isCollapsed: !!config.groupStates[groupKey]?.isCollapsed }; group.activityKeys.push(activityKey); groups.set(groupKey, group); } - for (const [, group] of groups.entries()) { - addLogicalGrouping(group); + for (const [groupKey, group] of groups.entries()) { + setLogicalGrouping(groupKey, group); } - }, [addLogicalGrouping]); + }, [setLogicalGrouping]); return null; }; diff --git a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx index b6dbce4936..3db4682857 100644 --- a/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx +++ b/packages/component/src/providers/TranscriptFocus/TranscriptFocusComposer.tsx @@ -7,10 +7,10 @@ import usePrevious from '../../hooks/internal/usePrevious'; import useStateRef from '../../hooks/internal/useStateRef'; import useValueRef from '../../hooks/internal/useValueRef'; import scrollIntoViewWithBlockNearest from '../../Utils/scrollIntoViewWithBlockNearest'; -import { useGetGroupState, useGetLogicalGroupKey } from '../ActivityLogicalGrouping'; -import useGetLogicalGroupBoundaries from '../ActivityLogicalGrouping/useGetLogicalGroupBoundaries'; +import useLogicalGroupingStores from '../ActivityLogicalGrouping/private/useLogicalGroupingStores'; import useRenderingActivityKeys from '../RenderingActivities/useRenderingActivityKeys'; import TranscriptFocusContext, { type TranscriptFocusContextType } from './private/Context'; +import getDescendantId from './private/getDescendantId'; type TranscriptFocusComposerProps = Readonly<{ children?: ReactNode | undefined; @@ -29,29 +29,34 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp >(); const [renderingActivityKeys] = useRenderingActivityKeys(); const [, setRawFocusedActivityKey, rawFocusedActivityKeyRef] = useStateRef(); - const getGroupBoundaries = useGetLogicalGroupBoundaries(); - const getGroupKeyByActivityKey = useGetLogicalGroupKey(); - const getGroupState = useGetGroupState(); + // Keyboard navigation reads arbitrary activities and groups at event time and renders nothing from them: it does not subscribe. + const { logicalGroupKeyStore, logicalGroupingStore } = useLogicalGroupingStores(); + const getGroupKeyByActivityKey = logicalGroupKeyStore.get; + const getLogicalGrouping = logicalGroupingStore.get; + + const getGroupBoundaries = useCallback( + (groupKey: string): readonly [string | undefined, string | undefined] => { + const activityKeys = getLogicalGrouping(groupKey)?.activityKeys ?? []; + + return Object.freeze([activityKeys.at(0), activityKeys.at(-1)] as const); + }, + [getLogicalGrouping] + ); // As we need to use IDREF for `aria-activedescendant`, // this prefix will differentiate multiple instances of transcript on the same page. const prefix = useMemo(() => uniqueId(3), []); const getDescendantIdByActivityKey: (activityKey?: string) => string | undefined = useCallback( - (activityKey?: string) => activityKey && `webchat__transcript-focus-${prefix}__activity-${activityKey}`, + (activityKey?: string) => getDescendantId(prefix, 'activity', activityKey), [prefix] ); const getGroupDescendantIdByGroupKey: (groupKey?: string) => string | undefined = useCallback( - (groupKey?: string) => groupKey && `webchat__transcript-focus-${prefix}__group-${groupKey}`, + (groupKey?: string) => getDescendantId(prefix, 'group', groupKey), [prefix] ); - const getGroupDescendantIdByActivityKey: (activityKey?: string) => string | undefined = useCallback( - (activityKey?: string) => activityKey && getGroupDescendantIdByGroupKey(getGroupKeyByActivityKey(activityKey)), - [getGroupDescendantIdByGroupKey, getGroupKeyByActivityKey] - ); - const renderingActivityKeysRef = useValueRef(renderingActivityKeys); // While the transcript or any descendants are not focused, if the transcript is updated, reset the user-selected active descendant. @@ -132,7 +137,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp } const groupKey = typeof activityKey === 'string' && getGroupKeyByActivityKey(activityKey); - const groupState = groupKey && getGroupState(groupKey); + const groupState = groupKey && getLogicalGrouping(groupKey); // shortcut for collapsed group // when collapsed, we do not want to focus on the group or scroll it into view @@ -159,7 +164,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusedActivityKeyRef, getDescendantIdByActivityKey, getGroupKeyByActivityKey, - getGroupState, + getLogicalGrouping, handleFocus, rawFocusedActivityKeyRef, renderingActivityKeysRef, @@ -194,7 +199,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp (direction: 'up' | 'down') => { const { current: currentGroupKey } = rawFocusedActivityGroupKeyRef; const { current: orderedActivityKeys } = renderingActivityKeysRef; - const currentGroupState = getGroupState(currentGroupKey); + const currentGroupState = getLogicalGrouping(currentGroupKey); const [firstActivityKey, lastActivityKey] = getGroupBoundaries(currentGroupKey); if (direction === 'down') { @@ -227,7 +232,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp const prevActivityKey = orderedActivityKeys[currentIndex - 1]; const prevGroupKey = getGroupKeyByActivityKey(prevActivityKey); const prevGroupState = - prevGroupKey && prevGroupKey !== currentGroupKey ? getGroupState(prevGroupKey) : undefined; + prevGroupKey && prevGroupKey !== currentGroupKey ? getLogicalGrouping(prevGroupKey) : undefined; // Previous activity is in a different collapsed group if (prevGroupState?.isCollapsed) { @@ -244,7 +249,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByGroupKey, getGroupBoundaries, getGroupKeyByActivityKey, - getGroupState, + getLogicalGrouping, rawFocusedActivityGroupKeyRef, renderingActivityKeysRef ] @@ -264,7 +269,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp // Moving to a different group const [firstActivityKey, lastActivityKey] = getGroupBoundaries(nextGroupKey); - const nextGroupState = getGroupState(nextGroupKey); + const nextGroupState = getLogicalGrouping(nextGroupKey); if (direction === 'down' && firstActivityKey === nextActivityKey) { // Going down into a new group - always focus header first @@ -278,7 +283,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusByActivityKey(nextActivityKey, true); } }, - [focusByActivityKey, focusByGroupKey, getGroupBoundaries, getGroupKeyByActivityKey, getGroupState] + [focusByActivityKey, focusByGroupKey, getGroupBoundaries, getGroupKeyByActivityKey, getLogicalGrouping] ); // Helper function to handle delta > 1 navigation @@ -347,7 +352,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp // Handle navigation from a focused activity const groupKey = getGroupKeyByActivityKey(focusedActivityKey); const [firstActivityKey, lastActivityKey] = getGroupBoundaries(groupKey); - const groupState = groupKey ? getGroupState(groupKey) : undefined; + const groupState = groupKey ? getLogicalGrouping(groupKey) : undefined; // In group special cases if (groupKey) { @@ -383,7 +388,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusedActivityKeyRef, getGroupBoundaries, getGroupKeyByActivityKey, - getGroupState, + getLogicalGrouping, getNextActivityLinear, handleDeltaNavigation, handleGroupTransition, @@ -404,7 +409,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusedGroupKeyState: Object.freeze([rawFocusedActivityGroupKey]) as readonly [string], focusRelativeActivity, getDescendantIdByActivityKey, - getGroupDescendantIdByActivityKey + prefix }), [ activeDescendantId, @@ -414,7 +419,7 @@ const TranscriptFocusComposer = ({ children, containerRef }: TranscriptFocusComp focusedActivityKey, focusRelativeActivity, getDescendantIdByActivityKey, - getGroupDescendantIdByActivityKey, + prefix, rawFocusedActivityGroupKey, rawFocusedActivityKey ] diff --git a/packages/component/src/providers/TranscriptFocus/private/Context.ts b/packages/component/src/providers/TranscriptFocus/private/Context.ts index 7ae4f5dc40..46859baaa5 100644 --- a/packages/component/src/providers/TranscriptFocus/private/Context.ts +++ b/packages/component/src/providers/TranscriptFocus/private/Context.ts @@ -10,7 +10,8 @@ type TranscriptFocusContextType = { focusedGroupKeyState: readonly [string]; focusRelativeActivity: (delta: number) => void; getDescendantIdByActivityKey: (activityKey?: string) => string | undefined; - getGroupDescendantIdByActivityKey: (activityKey?: string) => string | undefined; + /** Differentiates the descendant IDs of multiple transcripts on the same page. */ + prefix: string; }; const TranscriptFocusContext = createContext(undefined); diff --git a/packages/component/src/providers/TranscriptFocus/private/getDescendantId.ts b/packages/component/src/providers/TranscriptFocus/private/getDescendantId.ts new file mode 100644 index 0000000000..39bdd2688c --- /dev/null +++ b/packages/component/src/providers/TranscriptFocus/private/getDescendantId.ts @@ -0,0 +1,8 @@ +/** Returns the ID of the activity or group for `aria-activedescendant`, or `undefined` without a key. */ +export default function getDescendantId( + prefix: string, + type: 'activity' | 'group', + key?: string | undefined +): string | undefined { + return key && `webchat__transcript-focus-${prefix}__${type}-${key}`; +} diff --git a/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByActivityKey.ts b/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByActivityKey.ts deleted file mode 100644 index 452249399c..0000000000 --- a/packages/component/src/providers/TranscriptFocus/useGetGroupDescendantIdByActivityKey.ts +++ /dev/null @@ -1,5 +0,0 @@ -import useTranscriptFocusContext from './private/useContext'; - -export default function useGetGroupDescendantIdByActivityKey(): (activityKey?: string) => string | undefined { - return useTranscriptFocusContext().getGroupDescendantIdByActivityKey; -} diff --git a/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts new file mode 100644 index 0000000000..530dee7b26 --- /dev/null +++ b/packages/component/src/providers/TranscriptFocus/useGroupDescendantIdByGroupKey.ts @@ -0,0 +1,12 @@ +import { useMemo } from 'react'; + +import getDescendantId from './private/getDescendantId'; +import useTranscriptFocusContext from './private/useContext'; + +/** Returns the ID of the group for `aria-activedescendant`, or `undefined` without a group key. */ +export default function useGroupDescendantIdByGroupKey(groupKey?: string | undefined): readonly [string | undefined] { + const { prefix } = useTranscriptFocusContext(); + + // Derived from values only: a change of `prefix` or `groupKey` re-renders the caller. + return useMemo(() => Object.freeze([getDescendantId(prefix, 'group', groupKey)] as const), [groupKey, prefix]); +}