From cc4504e9f3a5309b665abaae5a0323b08c4aed3d Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 19 May 2026 13:46:50 +0200 Subject: [PATCH 1/3] feat(react-tags): decouple useTagGroupBase_unstable from Tabster; export contexts --- ...-80527846-5fc6-4b8e-adcc-b758379cefb2.json | 7 ++ .../react-tags/library/etc/react-tags.api.md | 27 ++++++- .../src/components/TagGroup/useTagGroup.ts | 71 +++++++++++-------- .../react-tags/library/src/index.ts | 6 ++ 4 files changed, 80 insertions(+), 31 deletions(-) create mode 100644 change/@fluentui-react-tags-80527846-5fc6-4b8e-adcc-b758379cefb2.json diff --git a/change/@fluentui-react-tags-80527846-5fc6-4b8e-adcc-b758379cefb2.json b/change/@fluentui-react-tags-80527846-5fc6-4b8e-adcc-b758379cefb2.json new file mode 100644 index 00000000000000..0d5c89c32ce012 --- /dev/null +++ b/change/@fluentui-react-tags-80527846-5fc6-4b8e-adcc-b758379cefb2.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Decouple useTagGroupBase_unstable from Tabster (pluggable arrowNavigationProps/onAfterTagDismiss options); export TagGroupContextProvider/useTagGroupContext_unstable and InteractionTagContextProvider/useInteractionTagContext_unstable for headless consumers", + "packageName": "@fluentui/react-tags", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-tags/library/etc/react-tags.api.md b/packages/react-components/react-tags/library/etc/react-tags.api.md index c857f87c6e18a5..80a35881e2c935 100644 --- a/packages/react-components/react-tags/library/etc/react-tags.api.md +++ b/packages/react-components/react-tags/library/etc/react-tags.api.md @@ -16,6 +16,7 @@ import type { JSXElement } from '@fluentui/react-utilities'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; +import type { TabsterDOMAttribute } from '@fluentui/react-tabster'; // @public export const InteractionTag: ForwardRefComponent; @@ -29,6 +30,18 @@ export type InteractionTagBaseState = Omit; +// @public (undocumented) +export const InteractionTagContextProvider: React_2.Provider | undefined>; + +// @public +export type InteractionTagContextValue = Required & { + handleTagDismiss: TagDismissHandler; + interactionTagPrimaryId: string; + value?: Value; +}> & { + handleTagSelect?: TagSelectHandler; +}; + // @public export const InteractionTagPrimary: ForwardRefComponent; @@ -160,6 +173,12 @@ export type TagGroupBaseState = Omit, 'ap // @public (undocumented) export const tagGroupClassNames: SlotClassNames; +// @public (undocumented) +export const TagGroupContextProvider: React_2.Provider; + +// @public +export type TagGroupContextValue = Required> & Partial>; + // @public (undocumented) export type TagGroupContextValues = { tagGroup: TagGroupContextValue; @@ -228,6 +247,9 @@ export const useInteractionTag_unstable: (props: InteractionTagProps, ref: React // @public export const useInteractionTagBase_unstable: (props: InteractionTagBaseProps, ref: React_2.Ref) => InteractionTagBaseState; +// @public (undocumented) +export const useInteractionTagContext_unstable: () => InteractionTagContextValue; + // @public (undocumented) export function useInteractionTagContextValues_unstable(state: InteractionTagState): InteractionTagContextValues; @@ -265,7 +287,10 @@ export const useTagBase_unstable: (props: TagBaseProps, ref: React_2.Ref) => TagGroupState; // @public -export const useTagGroupBase_unstable: (props: TagGroupBaseProps, ref: React_2.Ref) => TagGroupBaseState; +export const useTagGroupBase_unstable: (props: TagGroupBaseProps, ref: React_2.Ref, options?: UseTagGroupBaseOptions) => TagGroupBaseState; + +// @public (undocumented) +export const useTagGroupContext_unstable: () => TagGroupContextValue; // @public (undocumented) export function useTagGroupContextValues_unstable(state: TagGroupState): TagGroupContextValues; diff --git a/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.ts b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.ts index 21dd23eb1b8c52..e6b0b19aca97f1 100644 --- a/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.ts +++ b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.ts @@ -14,6 +14,12 @@ import { useArrowNavigationGroup, useFocusFinders } from '@fluentui/react-tabste import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { interactionTagSecondaryClassNames } from '../InteractionTagSecondary/useInteractionTagSecondaryStyles.styles'; import type { TagValue } from '../../utils/types'; +import type { TabsterDOMAttribute } from '@fluentui/react-tabster'; + +type UseTagGroupBaseOptions = { + arrowNavigationProps?: TabsterDOMAttribute; + onAfterTagDismiss?: (container: HTMLElement | null) => void; +}; /** * Create the base state required to render TagGroup, without design-only props. @@ -24,6 +30,7 @@ import type { TagValue } from '../../utils/types'; export const useTagGroupBase_unstable = ( props: TagGroupBaseProps, ref: React.Ref, + options?: UseTagGroupBaseOptions, ): TagGroupBaseState => { const { onDismiss, @@ -37,8 +44,6 @@ export const useTagGroupBase_unstable = ( } = props; const innerRef = React.useRef(undefined); - const { targetDocument } = useFluent(); - const { findNextFocusable, findPrevFocusable } = useFocusFinders(); const [items, setItems] = useControllableState>({ defaultState: defaultSelectedValues, @@ -48,26 +53,7 @@ export const useTagGroupBase_unstable = ( const handleTagDismiss: TagGroupBaseState['handleTagDismiss'] = useEventCallback((e, data) => { onDismiss?.(e, data); - - // set focus after tag dismiss - const activeElement = targetDocument?.activeElement; - if (innerRef.current?.contains(activeElement as HTMLElement)) { - // focus on next tag only if the active element is within the current tag group - const next = findNextFocusable(activeElement as HTMLElement, { container: innerRef.current }); - if (next) { - next.focus(); - return; - } - - // if there is no next focusable, focus on the previous focusable - if (activeElement?.className.includes(interactionTagSecondaryClassNames.root)) { - const prev = findPrevFocusable(activeElement.parentElement as HTMLElement, { container: innerRef.current }); - prev?.focus(); - } else { - const prev = findPrevFocusable(activeElement as HTMLElement, { container: innerRef.current }); - prev?.focus(); - } - } + options?.onAfterTagDismiss?.(innerRef.current ?? null); }); const handleTagSelect: TagGroupBaseState['handleTagSelect'] = useEventCallback( @@ -80,12 +66,6 @@ export const useTagGroupBase_unstable = ( }), ); - const arrowNavigationProps = useArrowNavigationGroup({ - circular: true, - axis: 'both', - memorizeCurrent: true, - }); - return { handleTagDismiss, handleTagSelect: onTagSelect ? handleTagSelect : undefined, @@ -105,7 +85,7 @@ export const useTagGroupBase_unstable = ( ref: useMergedRefs(ref, innerRef) as React.Ref, role, 'aria-disabled': disabled, - ...arrowNavigationProps, + ...options?.arrowNavigationProps, ...rest, }), { elementType: 'div' }, @@ -124,8 +104,39 @@ export const useTagGroupBase_unstable = ( */ export const useTagGroup_unstable = (props: TagGroupProps, ref: React.Ref): TagGroupState => { const { size = 'medium', appearance = 'filled' } = props; + + const { targetDocument } = useFluent(); + const { findNextFocusable, findPrevFocusable } = useFocusFinders(); + + const arrowNavigationProps = useArrowNavigationGroup({ + circular: true, + axis: 'both', + memorizeCurrent: true, + }); + + const onAfterTagDismiss = useEventCallback((container: HTMLElement | null) => { + const activeElement = targetDocument?.activeElement; + if (container?.contains(activeElement as HTMLElement)) { + // focus on next tag only if the active element is within the current tag group + const next = findNextFocusable(activeElement as HTMLElement, { container }); + if (next) { + next.focus(); + return; + } + + // if there is no next focusable, focus on the previous focusable + if (activeElement?.className.includes(interactionTagSecondaryClassNames.root)) { + const prev = findPrevFocusable(activeElement.parentElement as HTMLElement, { container }); + prev?.focus(); + } else { + const prev = findPrevFocusable(activeElement as HTMLElement, { container }); + prev?.focus(); + } + } + }); + return { - ...useTagGroupBase_unstable(props, ref), + ...useTagGroupBase_unstable(props, ref, { arrowNavigationProps, onAfterTagDismiss }), size, appearance, }; diff --git a/packages/react-components/react-tags/library/src/index.ts b/packages/react-components/react-tags/library/src/index.ts index 65d3466f07dfa7..0fafec8438010a 100644 --- a/packages/react-components/react-tags/library/src/index.ts +++ b/packages/react-components/react-tags/library/src/index.ts @@ -74,6 +74,12 @@ export type { TagGroupContextValues, } from './TagGroup'; +export { TagGroupContextProvider, useTagGroupContext_unstable } from './contexts/tagGroupContext'; +export type { TagGroupContextValue } from './contexts/tagGroupContext'; + +export { InteractionTagContextProvider, useInteractionTagContext_unstable } from './contexts/interactionTagContext'; +export type { InteractionTagContextValue } from './contexts/interactionTagContext'; + export { useTagAvatarContextValues_unstable } from './utils'; export type { TagAppearance, From 01c76c3a6056a8e3f15f3bea4992e8dd78124e1c Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 19 May 2026 14:39:32 +0200 Subject: [PATCH 2/3] feat(react-tags): export TagContextValues type from package entry --- .../react-components/react-tags/library/etc/react-tags.api.md | 3 +++ packages/react-components/react-tags/library/src/index.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-tags/library/etc/react-tags.api.md b/packages/react-components/react-tags/library/etc/react-tags.api.md index 80a35881e2c935..c32730f67ad424 100644 --- a/packages/react-components/react-tags/library/etc/react-tags.api.md +++ b/packages/react-components/react-tags/library/etc/react-tags.api.md @@ -150,6 +150,9 @@ export type TagBaseState = DistributiveOmit; +// @public (undocumented) +export type TagContextValues = TagAvatarContextValues; + // @public (undocumented) export type TagDismissData = { value: Value; diff --git a/packages/react-components/react-tags/library/src/index.ts b/packages/react-components/react-tags/library/src/index.ts index 0fafec8438010a..ac46009bdaf2a9 100644 --- a/packages/react-components/react-tags/library/src/index.ts +++ b/packages/react-components/react-tags/library/src/index.ts @@ -6,7 +6,7 @@ export { useTagBase_unstable, useTag_unstable, } from './Tag'; -export type { TagBaseProps, TagBaseState, TagProps, TagSlots, TagState } from './Tag'; +export type { TagBaseProps, TagBaseState, TagContextValues, TagProps, TagSlots, TagState } from './Tag'; export { InteractionTag, From 08cb5a5327d6ee2375ca634051440fc96e7c83f2 Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 19 May 2026 15:51:02 +0200 Subject: [PATCH 3/3] chore: update change file --- ...luentui-react-tags-80527846-5fc6-4b8e-adcc-b758379cefb2.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/change/@fluentui-react-tags-80527846-5fc6-4b8e-adcc-b758379cefb2.json b/change/@fluentui-react-tags-80527846-5fc6-4b8e-adcc-b758379cefb2.json index 0d5c89c32ce012..c39e67e9831c2c 100644 --- a/change/@fluentui-react-tags-80527846-5fc6-4b8e-adcc-b758379cefb2.json +++ b/change/@fluentui-react-tags-80527846-5fc6-4b8e-adcc-b758379cefb2.json @@ -1,6 +1,6 @@ { "type": "minor", - "comment": "Decouple useTagGroupBase_unstable from Tabster (pluggable arrowNavigationProps/onAfterTagDismiss options); export TagGroupContextProvider/useTagGroupContext_unstable and InteractionTagContextProvider/useInteractionTagContext_unstable for headless consumers", + "comment": "feat: export contexts for headless", "packageName": "@fluentui/react-tags", "email": "vgenaev@gmail.com", "dependentChangeType": "patch"