From cc4504e9f3a5309b665abaae5a0323b08c4aed3d Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 19 May 2026 13:46:50 +0200 Subject: [PATCH 1/8] 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/8] 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/8] 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" From f1a66a184f8ec5d9c9fb4ec45ec87e429dcea94b Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 19 May 2026 14:28:23 +0200 Subject: [PATCH 4/8] test(react-tags): add hook regression tests for Tag family base + styled hooks --- .../InteractionTag/useInteractionTag.test.tsx | 96 ++++++++++++++ .../useInteractionTagPrimary.test.tsx | 82 ++++++++++++ .../useInteractionTagSecondary.test.tsx | 105 +++++++++++++++ .../src/components/Tag/useTag.test.tsx | 120 ++++++++++++++++-- .../components/TagGroup/useTagGroup.test.tsx | 92 ++++++++++++++ 5 files changed, 482 insertions(+), 13 deletions(-) create mode 100644 packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx create mode 100644 packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx create mode 100644 packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx create mode 100644 packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx diff --git a/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx b/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx new file mode 100644 index 00000000000000..ddab54d1f7afcb --- /dev/null +++ b/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx @@ -0,0 +1,96 @@ +import { renderHook } from '@testing-library/react-hooks'; +import * as React from 'react'; + +import { TagGroupContextProvider } from '../../contexts/tagGroupContext'; +import { useInteractionTag_unstable, useInteractionTagBase_unstable } from './useInteractionTag'; + +const wrap = ( + contextOverrides: Parameters[0]['value'] = { + handleTagDismiss: () => ({}), + size: 'medium', + }, +): React.FC<{ children?: React.ReactNode }> => { + const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( + {children} + ); + return Wrapper; +}; + +describe('useInteractionTag_unstable', () => { + it('should add design-only fields (appearance, shape, size) on top of the base state', () => { + const ref = React.createRef(); + const { result } = renderHook( + () => useInteractionTag_unstable({ appearance: 'outline', shape: 'circular', size: 'small' }, ref), + { + wrapper: wrap(), + }, + ); + + expect(result.current.appearance).toBe('outline'); + expect(result.current.shape).toBe('circular'); + expect(result.current.size).toBe('small'); + }); + + it('should default appearance to filled and shape to rounded', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTag_unstable({}, ref), { wrapper: wrap() }); + + expect(result.current.appearance).toBe('filled'); + expect(result.current.shape).toBe('rounded'); + }); + + it('should inherit appearance and size from TagGroupContext when not set on props', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTag_unstable({}, ref), { + wrapper: wrap({ handleTagDismiss: () => ({}), size: 'extra-small', appearance: 'brand' }), + }); + + expect(result.current.appearance).toBe('brand'); + expect(result.current.size).toBe('extra-small'); + }); +}); + +describe('useInteractionTagBase_unstable', () => { + it('should NOT expose design-only fields (appearance/shape/size) on base state', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagBase_unstable({}, ref), { wrapper: wrap() }); + + expect((result.current as unknown as { appearance?: unknown }).appearance).toBeUndefined(); + expect((result.current as unknown as { shape?: unknown }).shape).toBeUndefined(); + expect((result.current as unknown as { size?: unknown }).size).toBeUndefined(); + }); + + it('should force disabled when TagGroupContext.disabled is true regardless of props', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagBase_unstable({ disabled: false }, ref), { + wrapper: wrap({ handleTagDismiss: () => ({}), size: 'medium', disabled: true }), + }); + expect(result.current.disabled).toBe(true); + }); + + it('should derive selected from props OR context.selectedValues containing the tag value', () => { + const ref = React.createRef(); + + const propSelected = renderHook(() => useInteractionTagBase_unstable({ selected: true, value: 'a' }, ref), { + wrapper: wrap(), + }); + expect(propSelected.result.current.selected).toBe(true); + + const contextSelected = renderHook(() => useInteractionTagBase_unstable({ value: 'a' }, ref), { + wrapper: wrap({ handleTagDismiss: () => ({}), size: 'medium', selectedValues: ['a'] }), + }); + expect(contextSelected.result.current.selected).toBe(true); + + const notSelected = renderHook(() => useInteractionTagBase_unstable({ value: 'b' }, ref), { + wrapper: wrap({ handleTagDismiss: () => ({}), size: 'medium', selectedValues: ['a'] }), + }); + expect(notSelected.result.current.selected).toBe(false); + }); + + it('should generate interactionTagPrimaryId for use by aria-labelledby', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagBase_unstable({}, ref), { wrapper: wrap() }); + + expect(result.current.interactionTagPrimaryId).toEqual(expect.stringMatching(/^fui-InteractionTagPrimary-/)); + }); +}); diff --git a/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx b/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx new file mode 100644 index 00000000000000..b64e5a312fe6b0 --- /dev/null +++ b/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx @@ -0,0 +1,82 @@ +import { renderHook } from '@testing-library/react-hooks'; +import * as React from 'react'; + +import { InteractionTagContextProvider } from '../../contexts/interactionTagContext'; +import { useInteractionTagPrimary_unstable, useInteractionTagPrimaryBase_unstable } from './useInteractionTagPrimary'; + +const baseContext = { + appearance: 'filled' as const, + disabled: false, + handleTagDismiss: () => ({}), + interactionTagPrimaryId: 'fui-InteractionTagPrimary-_test_', + selected: false, + selectedValues: [], + shape: 'rounded' as const, + size: 'medium' as const, + value: 'test', +}; + +const wrap = ( + overrides: Partial[0]['value']> = {}, +): React.FC<{ children?: React.ReactNode }> => { + const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( + {children} + ); + return Wrapper; +}; + +describe('useInteractionTagPrimary_unstable', () => { + it('should add design-only fields (appearance, shape, size, avatar*) on top of the base state', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagPrimary_unstable({}, ref), { + wrapper: wrap({ appearance: 'brand', shape: 'circular', size: 'small' }), + }); + + expect(result.current.appearance).toBe('brand'); + expect(result.current.shape).toBe('circular'); + expect(result.current.size).toBe('small'); + expect(result.current.avatarShape).toBe('circular'); + expect(result.current.avatarSize).toBe(20); + }); +}); + +describe('useInteractionTagPrimaryBase_unstable', () => { + it('should render root with the interactionTagPrimaryId from context', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { wrapper: wrap() }); + expect(result.current.root.id).toBe('fui-InteractionTagPrimary-_test_'); + }); + + it('should NOT expose design-only fields (appearance/shape/size/avatar*) on base state', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { wrapper: wrap() }); + + expect((result.current as unknown as { appearance?: unknown }).appearance).toBeUndefined(); + expect((result.current as unknown as { shape?: unknown }).shape).toBeUndefined(); + expect((result.current as unknown as { size?: unknown }).size).toBeUndefined(); + expect((result.current as unknown as { avatarShape?: unknown }).avatarShape).toBeUndefined(); + expect((result.current as unknown as { avatarSize?: unknown }).avatarSize).toBeUndefined(); + }); + + it('should set aria-pressed when context has handleTagSelect (selectable group)', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { + wrapper: wrap({ selected: true, handleTagSelect: () => ({}) }), + }); + expect(result.current.root['aria-pressed']).toBe(true); + }); + + it('should NOT set aria-pressed when context has no handleTagSelect', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { + wrapper: wrap({ selected: true, handleTagSelect: undefined }), + }); + expect(result.current.root['aria-pressed']).toBeUndefined(); + }); + + it('should default hasSecondaryAction to false', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { wrapper: wrap() }); + expect(result.current.hasSecondaryAction).toBe(false); + }); +}); diff --git a/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx b/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx new file mode 100644 index 00000000000000..fdc13fb5c760b0 --- /dev/null +++ b/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx @@ -0,0 +1,105 @@ +import { renderHook } from '@testing-library/react-hooks'; +import * as React from 'react'; + +import { InteractionTagContextProvider } from '../../contexts/interactionTagContext'; +import { + useInteractionTagSecondary_unstable, + useInteractionTagSecondaryBase_unstable, +} from './useInteractionTagSecondary'; + +const baseContext = { + appearance: 'filled' as const, + disabled: false, + handleTagDismiss: () => ({}), + interactionTagPrimaryId: 'fui-InteractionTagPrimary-_test_', + selected: false, + selectedValues: [], + shape: 'rounded' as const, + size: 'medium' as const, + value: 'test', +}; + +const wrap = ( + overrides: Partial[0]['value']> = {}, +): React.FC<{ children?: React.ReactNode }> => { + const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( + {children} + ); + return Wrapper; +}; + +describe('useInteractionTagSecondary_unstable', () => { + it('should inject DismissRegular as default root children', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagSecondary_unstable({}, ref), { wrapper: wrap() }); + expect(result.current.root.children).toBeDefined(); + }); + + it('should preserve user-provided children instead of the default DismissRegular', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagSecondary_unstable({ children: 'X' }, ref), { + wrapper: wrap(), + }); + expect(result.current.root.children).toBe('X'); + }); + + it('should inherit appearance/shape/size from context', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagSecondary_unstable({}, ref), { + wrapper: wrap({ appearance: 'outline', shape: 'circular', size: 'small' }), + }); + expect(result.current.appearance).toBe('outline'); + expect(result.current.shape).toBe('circular'); + expect(result.current.size).toBe('small'); + }); +}); + +describe('useInteractionTagSecondaryBase_unstable', () => { + it('should render root with type="button"', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() }); + expect(result.current.root.type).toBe('button'); + }); + + it('should NOT inject DismissRegular children by default (icon injection lives in the styled hook)', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() }); + expect(result.current.root.children).toBeUndefined(); + }); + + it('should attach onClick and onKeyDown handlers', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() }); + expect(result.current.root.onClick).toBeDefined(); + expect(result.current.root.onKeyDown).toBeDefined(); + }); + + it('should build aria-labelledby from interactionTagPrimaryId and own id', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() }); + expect(result.current.root['aria-labelledby']).toEqual( + expect.stringMatching(/^fui-InteractionTagPrimary-_test_ fui-InteractionTagSecondary-/), + ); + }); + + it('should NOT expose design-only fields (appearance/shape/size)', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() }); + expect((result.current as unknown as { appearance?: unknown }).appearance).toBeUndefined(); + expect((result.current as unknown as { shape?: unknown }).shape).toBeUndefined(); + expect((result.current as unknown as { size?: unknown }).size).toBeUndefined(); + }); + + it('should call handleTagDismiss on Delete/Backspace keyDown via context', () => { + const handleTagDismiss = jest.fn(); + const ref = React.createRef(); + const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { + wrapper: wrap({ handleTagDismiss, value: 'val' }), + }); + + const event = { key: 'Delete', defaultPrevented: false } as unknown as React.KeyboardEvent; + result.current.root.onKeyDown?.(event); + + expect(handleTagDismiss).toHaveBeenCalledWith(event, { value: 'val' }); + }); +}); diff --git a/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx b/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx index ea92611df60277..4753d96a8406f0 100644 --- a/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx +++ b/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx @@ -2,7 +2,19 @@ import { renderHook } from '@testing-library/react-hooks'; import * as React from 'react'; import { TagGroupContextProvider } from '../../contexts/tagGroupContext'; -import { useTag_unstable } from './useTag'; +import { useTag_unstable, useTagBase_unstable } from './useTag'; + +const wrap = ( + contextOverrides: Parameters[0]['value'] = { + handleTagDismiss: () => ({}), + size: 'medium', + }, +): React.FC<{ children?: React.ReactNode }> => { + const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( + {children} + ); + return Wrapper; +}; describe('useTag_unstable', () => { it.each([true, false])('should %s attach click event handler for tag when dismissible:$dismissible', dismissible => { @@ -11,22 +23,104 @@ describe('useTag_unstable', () => { // We don't want 'clickable' announcement when Tag is a simple span and not dismissible. const ref = React.createRef(); - const wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( - ({}), - size: 'medium', - }} - > - {children} - - ); - - const { result } = renderHook(() => useTag_unstable({ dismissible }, ref), { wrapper }); + const { result } = renderHook(() => useTag_unstable({ dismissible }, ref), { wrapper: wrap() }); if (dismissible) { expect(result.current.root.onClick).toBeDefined(); } else { expect(result.current.root.onClick).toBeUndefined(); } }); + + it('should add design-only fields (appearance, shape, size, avatar*) on top of the base state', () => { + const ref = React.createRef(); + const { result } = renderHook( + () => useTag_unstable({ appearance: 'outline', shape: 'circular', size: 'small' }, ref), + { + wrapper: wrap(), + }, + ); + + expect(result.current.appearance).toBe('outline'); + expect(result.current.shape).toBe('circular'); + expect(result.current.size).toBe('small'); + expect(result.current.avatarShape).toBe('circular'); + expect(result.current.avatarSize).toBe(20); + }); + + it('should inject DismissRegular as default dismissIcon children when dismissible', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTag_unstable({ dismissible: true }, ref), { wrapper: wrap() }); + + expect(result.current.dismissIcon).toBeDefined(); + expect(result.current.dismissIcon?.children).toBeDefined(); + }); + + it('should inherit appearance and size from TagGroupContext when not set on props', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTag_unstable({}, ref), { + wrapper: wrap({ handleTagDismiss: () => ({}), size: 'extra-small', appearance: 'brand' }), + }); + + expect(result.current.appearance).toBe('brand'); + expect(result.current.size).toBe('extra-small'); + }); +}); + +describe('useTagBase_unstable', () => { + it('should NOT attach onClick/onKeyDown handlers when not dismissible', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagBase_unstable({}, ref), { wrapper: wrap() }); + expect(result.current.root.onClick).toBeUndefined(); + expect(result.current.root.onKeyDown).toBeUndefined(); + }); + + it('should attach onClick/onKeyDown handlers when dismissible', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagBase_unstable({ dismissible: true }, ref), { wrapper: wrap() }); + expect(result.current.root.onClick).toBeDefined(); + expect(result.current.root.onKeyDown).toBeDefined(); + expect(result.current.root.type).toBe('button'); + }); + + it('should NOT inject a default dismissIcon children (icon injection lives in the styled hook)', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagBase_unstable({ dismissible: true }, ref), { wrapper: wrap() }); + + // dismissIcon slot is rendered by default when dismissible, but no children are injected by base + expect(result.current.dismissIcon).toBeDefined(); + expect(result.current.dismissIcon?.children).toBeUndefined(); + }); + + it('should set aria-selected when TagGroupContext role is listbox', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagBase_unstable({ selected: true }, ref), { + wrapper: wrap({ handleTagDismiss: () => ({}), size: 'medium', role: 'listbox' }), + }); + expect(result.current.root['aria-selected']).toBe(true); + expect(result.current.root.role).toBe('option'); + }); + + it('should use aria-pressed when selected is a boolean and TagGroupContext role is not listbox', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagBase_unstable({ selected: true }, ref), { wrapper: wrap() }); + expect(result.current.root['aria-pressed']).toBe(true); + }); + + it('should force disabled when TagGroupContext.disabled is true regardless of props', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagBase_unstable({ disabled: false, dismissible: true }, ref), { + wrapper: wrap({ handleTagDismiss: () => ({}), size: 'medium', disabled: true }), + }); + expect(result.current.disabled).toBe(true); + expect(result.current.root.disabled).toBe(true); + }); + + it('should inherit dismissible from TagGroupContext when not set on props', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagBase_unstable({}, ref), { + wrapper: wrap({ handleTagDismiss: () => ({}), size: 'medium', dismissible: true }), + }); + expect(result.current.dismissible).toBe(true); + expect(result.current.root.type).toBe('button'); + }); }); diff --git a/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx new file mode 100644 index 00000000000000..a67255b15da985 --- /dev/null +++ b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx @@ -0,0 +1,92 @@ +import { renderHook } from '@testing-library/react-hooks'; +import * as React from 'react'; + +import { useTagGroup_unstable, useTagGroupBase_unstable } from './useTagGroup'; + +describe('useTagGroup_unstable', () => { + it('should default size to medium and appearance to filled', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagGroup_unstable({}, ref)); + + expect(result.current.size).toBe('medium'); + expect(result.current.appearance).toBe('filled'); + }); + + it('should spread Tabster arrow-navigation attributes onto root (via UseTagGroupBaseOptions)', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagGroup_unstable({}, ref)); + + // useTagGroup_unstable wires up useArrowNavigationGroup via the base hook's + // arrowNavigationProps option; Tabster's contract is a data-tabster attribute. + expect(result.current.root['data-tabster']).toBeDefined(); + }); + + it('should default role to toolbar', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagGroup_unstable({}, ref)); + expect(result.current.role).toBe('toolbar'); + expect(result.current.root.role).toBe('toolbar'); + }); +}); + +describe('useTagGroupBase_unstable', () => { + it('should NOT include arrow-navigation props when options omitted (true headless mode)', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagGroupBase_unstable({}, ref)); + expect(result.current.root['data-tabster']).toBeUndefined(); + }); + + it('should spread arrowNavigationProps option onto root when supplied', () => { + const ref = React.createRef(); + const arrowNavigationProps = { 'data-arrow': 'group', tabIndex: 0 }; + const { result } = renderHook(() => useTagGroupBase_unstable({}, ref, { arrowNavigationProps })); + + expect(result.current.root['data-arrow']).toBe('group'); + expect(result.current.root.tabIndex).toBe(0); + }); + + it('should call onAfterTagDismiss with the group container after a tag is dismissed', () => { + const onAfterTagDismiss = jest.fn(); + const onDismiss = jest.fn(); + const ref = React.createRef(); + const { result } = renderHook(() => useTagGroupBase_unstable({ onDismiss }, ref, { onAfterTagDismiss })); + + const event = {} as React.MouseEvent; + result.current.handleTagDismiss(event, { value: 'v1' }); + + expect(onDismiss).toHaveBeenCalledWith(event, { value: 'v1' }); + // innerRef hasn't been attached to a DOM node in the renderHook environment, + // so the container argument is null - we still expect the callback to be invoked. + expect(onAfterTagDismiss).toHaveBeenCalledWith(null); + }); + + it('should NOT throw when onDismiss/onAfterTagDismiss are omitted (true headless mode)', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagGroupBase_unstable({}, ref)); + + expect(() => result.current.handleTagDismiss({} as React.MouseEvent, { value: 'v1' })).not.toThrow(); + }); + + it('should set aria-disabled on the root when disabled', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagGroupBase_unstable({ disabled: true }, ref)); + expect(result.current.root['aria-disabled']).toBe(true); + }); + + it('should NOT expose design-only fields (size, appearance) on base state', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagGroupBase_unstable({}, ref)); + expect((result.current as unknown as { size?: unknown }).size).toBeUndefined(); + expect((result.current as unknown as { appearance?: unknown }).appearance).toBeUndefined(); + }); + + it('should provide handleTagSelect only when onTagSelect is supplied', () => { + const ref = React.createRef(); + const without = renderHook(() => useTagGroupBase_unstable({}, ref)); + expect(without.result.current.handleTagSelect).toBeUndefined(); + + const onTagSelect = jest.fn(); + const withSelect = renderHook(() => useTagGroupBase_unstable({ onTagSelect }, ref)); + expect(withSelect.result.current.handleTagSelect).toBeDefined(); + }); +}); From e47c1f542fd0cb6b3aee296204157ad42e4c2f47 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 20 May 2026 00:43:47 +0200 Subject: [PATCH 5/8] fix(react-tags): cast slot props in regression tests to satisfy TS --- .../library/src/components/Tag/useTag.test.tsx | 13 ++++++++----- .../src/components/TagGroup/useTagGroup.test.tsx | 14 +++++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx b/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx index 4753d96a8406f0..d29496e10c9c23 100644 --- a/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx +++ b/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx @@ -77,9 +77,10 @@ describe('useTagBase_unstable', () => { it('should attach onClick/onKeyDown handlers when dismissible', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagBase_unstable({ dismissible: true }, ref), { wrapper: wrap() }); - expect(result.current.root.onClick).toBeDefined(); - expect(result.current.root.onKeyDown).toBeDefined(); - expect(result.current.root.type).toBe('button'); + const root = result.current.root as React.ButtonHTMLAttributes; + expect(root.onClick).toBeDefined(); + expect(root.onKeyDown).toBeDefined(); + expect(root.type).toBe('button'); }); it('should NOT inject a default dismissIcon children (icon injection lives in the styled hook)', () => { @@ -112,7 +113,8 @@ describe('useTagBase_unstable', () => { wrapper: wrap({ handleTagDismiss: () => ({}), size: 'medium', disabled: true }), }); expect(result.current.disabled).toBe(true); - expect(result.current.root.disabled).toBe(true); + const root = result.current.root as React.ButtonHTMLAttributes; + expect(root.disabled).toBe(true); }); it('should inherit dismissible from TagGroupContext when not set on props', () => { @@ -121,6 +123,7 @@ describe('useTagBase_unstable', () => { wrapper: wrap({ handleTagDismiss: () => ({}), size: 'medium', dismissible: true }), }); expect(result.current.dismissible).toBe(true); - expect(result.current.root.type).toBe('button'); + const root = result.current.root as React.ButtonHTMLAttributes; + expect(root.type).toBe('button'); }); }); diff --git a/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx index a67255b15da985..77dfef17dc09e1 100644 --- a/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx +++ b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx @@ -1,8 +1,13 @@ import { renderHook } from '@testing-library/react-hooks'; import * as React from 'react'; +import type { TabsterDOMAttribute } from '@fluentui/react-tabster'; import { useTagGroup_unstable, useTagGroupBase_unstable } from './useTagGroup'; +// Slot props are a discriminated union that doesn't expose `data-*` index access at +// the type level; cast to a plain record for the data-attribute assertions below. +type RootRecord = Record; + describe('useTagGroup_unstable', () => { it('should default size to medium and appearance to filled', () => { const ref = React.createRef(); @@ -18,7 +23,7 @@ describe('useTagGroup_unstable', () => { // useTagGroup_unstable wires up useArrowNavigationGroup via the base hook's // arrowNavigationProps option; Tabster's contract is a data-tabster attribute. - expect(result.current.root['data-tabster']).toBeDefined(); + expect((result.current.root as RootRecord)['data-tabster']).toBeDefined(); }); it('should default role to toolbar', () => { @@ -33,16 +38,15 @@ describe('useTagGroupBase_unstable', () => { it('should NOT include arrow-navigation props when options omitted (true headless mode)', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagGroupBase_unstable({}, ref)); - expect(result.current.root['data-tabster']).toBeUndefined(); + expect((result.current.root as RootRecord)['data-tabster']).toBeUndefined(); }); it('should spread arrowNavigationProps option onto root when supplied', () => { const ref = React.createRef(); - const arrowNavigationProps = { 'data-arrow': 'group', tabIndex: 0 }; + const arrowNavigationProps: TabsterDOMAttribute = { 'data-tabster': '{"mock":"value"}' }; const { result } = renderHook(() => useTagGroupBase_unstable({}, ref, { arrowNavigationProps })); - expect(result.current.root['data-arrow']).toBe('group'); - expect(result.current.root.tabIndex).toBe(0); + expect((result.current.root as RootRecord)['data-tabster']).toBe('{"mock":"value"}'); }); it('should call onAfterTagDismiss with the group container after a tag is dismissed', () => { From 28edd8994bf4888ab5051b87e4d5653c734bd3b0 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 20 May 2026 03:26:13 +0200 Subject: [PATCH 6/8] test(react-tags): tighten regression test assertions and drop narration comments --- .../InteractionTag/useInteractionTag.test.tsx | 6 +++--- .../useInteractionTagPrimary.test.tsx | 21 ++++++++++--------- .../useInteractionTagSecondary.test.tsx | 21 ++++++++++--------- .../src/components/Tag/useTag.test.tsx | 15 +++++++------ .../components/TagGroup/useTagGroup.test.tsx | 18 ++++++---------- 5 files changed, 38 insertions(+), 43 deletions(-) diff --git a/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx b/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx index ddab54d1f7afcb..c406c2c8fb58e0 100644 --- a/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx +++ b/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx @@ -55,9 +55,9 @@ describe('useInteractionTagBase_unstable', () => { const ref = React.createRef(); const { result } = renderHook(() => useInteractionTagBase_unstable({}, ref), { wrapper: wrap() }); - expect((result.current as unknown as { appearance?: unknown }).appearance).toBeUndefined(); - expect((result.current as unknown as { shape?: unknown }).shape).toBeUndefined(); - expect((result.current as unknown as { size?: unknown }).size).toBeUndefined(); + expect(result.current).not.toHaveProperty('appearance'); + expect(result.current).not.toHaveProperty('shape'); + expect(result.current).not.toHaveProperty('size'); }); it('should force disabled when TagGroupContext.disabled is true regardless of props', () => { diff --git a/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx b/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx index b64e5a312fe6b0..c825a501add539 100644 --- a/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx +++ b/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx @@ -2,17 +2,18 @@ import { renderHook } from '@testing-library/react-hooks'; import * as React from 'react'; import { InteractionTagContextProvider } from '../../contexts/interactionTagContext'; +import type { InteractionTagContextValue } from '../../contexts/interactionTagContext'; import { useInteractionTagPrimary_unstable, useInteractionTagPrimaryBase_unstable } from './useInteractionTagPrimary'; -const baseContext = { - appearance: 'filled' as const, +const baseContext: InteractionTagContextValue = { + appearance: 'filled', disabled: false, handleTagDismiss: () => ({}), interactionTagPrimaryId: 'fui-InteractionTagPrimary-_test_', selected: false, selectedValues: [], - shape: 'rounded' as const, - size: 'medium' as const, + shape: 'rounded', + size: 'medium', value: 'test', }; @@ -51,11 +52,11 @@ describe('useInteractionTagPrimaryBase_unstable', () => { const ref = React.createRef(); const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { wrapper: wrap() }); - expect((result.current as unknown as { appearance?: unknown }).appearance).toBeUndefined(); - expect((result.current as unknown as { shape?: unknown }).shape).toBeUndefined(); - expect((result.current as unknown as { size?: unknown }).size).toBeUndefined(); - expect((result.current as unknown as { avatarShape?: unknown }).avatarShape).toBeUndefined(); - expect((result.current as unknown as { avatarSize?: unknown }).avatarSize).toBeUndefined(); + expect(result.current).not.toHaveProperty('appearance'); + expect(result.current).not.toHaveProperty('shape'); + expect(result.current).not.toHaveProperty('size'); + expect(result.current).not.toHaveProperty('avatarShape'); + expect(result.current).not.toHaveProperty('avatarSize'); }); it('should set aria-pressed when context has handleTagSelect (selectable group)', () => { @@ -71,7 +72,7 @@ describe('useInteractionTagPrimaryBase_unstable', () => { const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { wrapper: wrap({ selected: true, handleTagSelect: undefined }), }); - expect(result.current.root['aria-pressed']).toBeUndefined(); + expect(result.current.root).not.toHaveProperty('aria-pressed'); }); it('should default hasSecondaryAction to false', () => { diff --git a/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx b/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx index fdc13fb5c760b0..58f8d6185f2f41 100644 --- a/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx +++ b/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx @@ -2,20 +2,21 @@ import { renderHook } from '@testing-library/react-hooks'; import * as React from 'react'; import { InteractionTagContextProvider } from '../../contexts/interactionTagContext'; +import type { InteractionTagContextValue } from '../../contexts/interactionTagContext'; import { useInteractionTagSecondary_unstable, useInteractionTagSecondaryBase_unstable, } from './useInteractionTagSecondary'; -const baseContext = { - appearance: 'filled' as const, +const baseContext: InteractionTagContextValue = { + appearance: 'filled', disabled: false, handleTagDismiss: () => ({}), interactionTagPrimaryId: 'fui-InteractionTagPrimary-_test_', selected: false, selectedValues: [], - shape: 'rounded' as const, - size: 'medium' as const, + shape: 'rounded', + size: 'medium', value: 'test', }; @@ -64,14 +65,14 @@ describe('useInteractionTagSecondaryBase_unstable', () => { it('should NOT inject DismissRegular children by default (icon injection lives in the styled hook)', () => { const ref = React.createRef(); const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() }); - expect(result.current.root.children).toBeUndefined(); + expect(result.current.root).not.toHaveProperty('children'); }); it('should attach onClick and onKeyDown handlers', () => { const ref = React.createRef(); const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() }); - expect(result.current.root.onClick).toBeDefined(); - expect(result.current.root.onKeyDown).toBeDefined(); + expect(result.current.root.onClick).toEqual(expect.any(Function)); + expect(result.current.root.onKeyDown).toEqual(expect.any(Function)); }); it('should build aria-labelledby from interactionTagPrimaryId and own id', () => { @@ -85,9 +86,9 @@ describe('useInteractionTagSecondaryBase_unstable', () => { it('should NOT expose design-only fields (appearance/shape/size)', () => { const ref = React.createRef(); const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() }); - expect((result.current as unknown as { appearance?: unknown }).appearance).toBeUndefined(); - expect((result.current as unknown as { shape?: unknown }).shape).toBeUndefined(); - expect((result.current as unknown as { size?: unknown }).size).toBeUndefined(); + expect(result.current).not.toHaveProperty('appearance'); + expect(result.current).not.toHaveProperty('shape'); + expect(result.current).not.toHaveProperty('size'); }); it('should call handleTagDismiss on Delete/Backspace keyDown via context', () => { diff --git a/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx b/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx index d29496e10c9c23..b18316bfe17b75 100644 --- a/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx +++ b/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx @@ -25,9 +25,9 @@ describe('useTag_unstable', () => { const ref = React.createRef(); const { result } = renderHook(() => useTag_unstable({ dismissible }, ref), { wrapper: wrap() }); if (dismissible) { - expect(result.current.root.onClick).toBeDefined(); + expect(result.current.root.onClick).toEqual(expect.any(Function)); } else { - expect(result.current.root.onClick).toBeUndefined(); + expect(result.current.root).not.toHaveProperty('onClick'); } }); @@ -70,16 +70,16 @@ describe('useTagBase_unstable', () => { it('should NOT attach onClick/onKeyDown handlers when not dismissible', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagBase_unstable({}, ref), { wrapper: wrap() }); - expect(result.current.root.onClick).toBeUndefined(); - expect(result.current.root.onKeyDown).toBeUndefined(); + expect(result.current.root).not.toHaveProperty('onClick'); + expect(result.current.root).not.toHaveProperty('onKeyDown'); }); it('should attach onClick/onKeyDown handlers when dismissible', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagBase_unstable({ dismissible: true }, ref), { wrapper: wrap() }); const root = result.current.root as React.ButtonHTMLAttributes; - expect(root.onClick).toBeDefined(); - expect(root.onKeyDown).toBeDefined(); + expect(root.onClick).toEqual(expect.any(Function)); + expect(root.onKeyDown).toEqual(expect.any(Function)); expect(root.type).toBe('button'); }); @@ -87,9 +87,8 @@ describe('useTagBase_unstable', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagBase_unstable({ dismissible: true }, ref), { wrapper: wrap() }); - // dismissIcon slot is rendered by default when dismissible, but no children are injected by base expect(result.current.dismissIcon).toBeDefined(); - expect(result.current.dismissIcon?.children).toBeUndefined(); + expect(result.current.dismissIcon).not.toHaveProperty('children'); }); it('should set aria-selected when TagGroupContext role is listbox', () => { diff --git a/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx index 77dfef17dc09e1..d6ba4f09ceeda4 100644 --- a/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx +++ b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx @@ -4,8 +4,6 @@ import type { TabsterDOMAttribute } from '@fluentui/react-tabster'; import { useTagGroup_unstable, useTagGroupBase_unstable } from './useTagGroup'; -// Slot props are a discriminated union that doesn't expose `data-*` index access at -// the type level; cast to a plain record for the data-attribute assertions below. type RootRecord = Record; describe('useTagGroup_unstable', () => { @@ -21,9 +19,7 @@ describe('useTagGroup_unstable', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagGroup_unstable({}, ref)); - // useTagGroup_unstable wires up useArrowNavigationGroup via the base hook's - // arrowNavigationProps option; Tabster's contract is a data-tabster attribute. - expect((result.current.root as RootRecord)['data-tabster']).toBeDefined(); + expect((result.current.root as RootRecord)['data-tabster']).toEqual(expect.any(String)); }); it('should default role to toolbar', () => { @@ -38,7 +34,7 @@ describe('useTagGroupBase_unstable', () => { it('should NOT include arrow-navigation props when options omitted (true headless mode)', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagGroupBase_unstable({}, ref)); - expect((result.current.root as RootRecord)['data-tabster']).toBeUndefined(); + expect(result.current.root).not.toHaveProperty('data-tabster'); }); it('should spread arrowNavigationProps option onto root when supplied', () => { @@ -59,8 +55,6 @@ describe('useTagGroupBase_unstable', () => { result.current.handleTagDismiss(event, { value: 'v1' }); expect(onDismiss).toHaveBeenCalledWith(event, { value: 'v1' }); - // innerRef hasn't been attached to a DOM node in the renderHook environment, - // so the container argument is null - we still expect the callback to be invoked. expect(onAfterTagDismiss).toHaveBeenCalledWith(null); }); @@ -80,17 +74,17 @@ describe('useTagGroupBase_unstable', () => { it('should NOT expose design-only fields (size, appearance) on base state', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagGroupBase_unstable({}, ref)); - expect((result.current as unknown as { size?: unknown }).size).toBeUndefined(); - expect((result.current as unknown as { appearance?: unknown }).appearance).toBeUndefined(); + expect(result.current).not.toHaveProperty('size'); + expect(result.current).not.toHaveProperty('appearance'); }); it('should provide handleTagSelect only when onTagSelect is supplied', () => { const ref = React.createRef(); const without = renderHook(() => useTagGroupBase_unstable({}, ref)); - expect(without.result.current.handleTagSelect).toBeUndefined(); + expect(without.result.current.handleTagSelect).toBe(undefined); const onTagSelect = jest.fn(); const withSelect = renderHook(() => useTagGroupBase_unstable({ onTagSelect }, ref)); - expect(withSelect.result.current.handleTagSelect).toBeDefined(); + expect(withSelect.result.current.handleTagSelect).toEqual(expect.any(Function)); }); }); From 37e0185bb940659cc73c83e584b7c4e8bcd0c591 Mon Sep 17 00:00:00 2001 From: mainframev Date: Mon, 25 May 2026 13:49:22 +0200 Subject: [PATCH 7/8] test(react-tags): address review feedback on Tag hook regression tests --- .../InteractionTag/useInteractionTag.test.tsx | 12 ++--------- .../useInteractionTagPrimary.test.tsx | 15 +------------- .../useInteractionTagSecondary.test.tsx | 16 +++------------ .../src/components/Tag/useTag.test.tsx | 5 +++-- .../components/TagGroup/useTagGroup.test.tsx | 20 ++----------------- 5 files changed, 11 insertions(+), 57 deletions(-) diff --git a/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx b/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx index c406c2c8fb58e0..0a6a481a539b1c 100644 --- a/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx +++ b/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx @@ -2,10 +2,11 @@ import { renderHook } from '@testing-library/react-hooks'; import * as React from 'react'; import { TagGroupContextProvider } from '../../contexts/tagGroupContext'; +import type { TagGroupContextValue } from '../../contexts/tagGroupContext'; import { useInteractionTag_unstable, useInteractionTagBase_unstable } from './useInteractionTag'; const wrap = ( - contextOverrides: Parameters[0]['value'] = { + contextOverrides: TagGroupContextValue = { handleTagDismiss: () => ({}), size: 'medium', }, @@ -51,15 +52,6 @@ describe('useInteractionTag_unstable', () => { }); describe('useInteractionTagBase_unstable', () => { - it('should NOT expose design-only fields (appearance/shape/size) on base state', () => { - const ref = React.createRef(); - const { result } = renderHook(() => useInteractionTagBase_unstable({}, ref), { wrapper: wrap() }); - - expect(result.current).not.toHaveProperty('appearance'); - expect(result.current).not.toHaveProperty('shape'); - expect(result.current).not.toHaveProperty('size'); - }); - it('should force disabled when TagGroupContext.disabled is true regardless of props', () => { const ref = React.createRef(); const { result } = renderHook(() => useInteractionTagBase_unstable({ disabled: false }, ref), { diff --git a/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx b/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx index c825a501add539..5f83eac3e0f581 100644 --- a/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx +++ b/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx @@ -17,9 +17,7 @@ const baseContext: InteractionTagContextValue = { value: 'test', }; -const wrap = ( - overrides: Partial[0]['value']> = {}, -): React.FC<{ children?: React.ReactNode }> => { +const wrap = (overrides: Partial = {}): React.FC<{ children?: React.ReactNode }> => { const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( {children} ); @@ -48,17 +46,6 @@ describe('useInteractionTagPrimaryBase_unstable', () => { expect(result.current.root.id).toBe('fui-InteractionTagPrimary-_test_'); }); - it('should NOT expose design-only fields (appearance/shape/size/avatar*) on base state', () => { - const ref = React.createRef(); - const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { wrapper: wrap() }); - - expect(result.current).not.toHaveProperty('appearance'); - expect(result.current).not.toHaveProperty('shape'); - expect(result.current).not.toHaveProperty('size'); - expect(result.current).not.toHaveProperty('avatarShape'); - expect(result.current).not.toHaveProperty('avatarSize'); - }); - it('should set aria-pressed when context has handleTagSelect (selectable group)', () => { const ref = React.createRef(); const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { diff --git a/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx b/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx index 58f8d6185f2f41..5408a8a3d39185 100644 --- a/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx +++ b/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx @@ -20,9 +20,7 @@ const baseContext: InteractionTagContextValue = { value: 'test', }; -const wrap = ( - overrides: Partial[0]['value']> = {}, -): React.FC<{ children?: React.ReactNode }> => { +const wrap = (overrides: Partial = {}): React.FC<{ children?: React.ReactNode }> => { const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( {children} ); @@ -33,7 +31,7 @@ describe('useInteractionTagSecondary_unstable', () => { it('should inject DismissRegular as default root children', () => { const ref = React.createRef(); const { result } = renderHook(() => useInteractionTagSecondary_unstable({}, ref), { wrapper: wrap() }); - expect(result.current.root.children).toBeDefined(); + expect(React.isValidElement(result.current.root.children)).toBe(true); }); it('should preserve user-provided children instead of the default DismissRegular', () => { @@ -62,7 +60,7 @@ describe('useInteractionTagSecondaryBase_unstable', () => { expect(result.current.root.type).toBe('button'); }); - it('should NOT inject DismissRegular children by default (icon injection lives in the styled hook)', () => { + it('should not inject children by default', () => { const ref = React.createRef(); const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() }); expect(result.current.root).not.toHaveProperty('children'); @@ -83,14 +81,6 @@ describe('useInteractionTagSecondaryBase_unstable', () => { ); }); - it('should NOT expose design-only fields (appearance/shape/size)', () => { - const ref = React.createRef(); - const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() }); - expect(result.current).not.toHaveProperty('appearance'); - expect(result.current).not.toHaveProperty('shape'); - expect(result.current).not.toHaveProperty('size'); - }); - it('should call handleTagDismiss on Delete/Backspace keyDown via context', () => { const handleTagDismiss = jest.fn(); const ref = React.createRef(); diff --git a/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx b/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx index b18316bfe17b75..08a883c4d5728d 100644 --- a/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx +++ b/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx @@ -2,10 +2,11 @@ import { renderHook } from '@testing-library/react-hooks'; import * as React from 'react'; import { TagGroupContextProvider } from '../../contexts/tagGroupContext'; +import type { TagGroupContextValue } from '../../contexts/tagGroupContext'; import { useTag_unstable, useTagBase_unstable } from './useTag'; const wrap = ( - contextOverrides: Parameters[0]['value'] = { + contextOverrides: TagGroupContextValue = { handleTagDismiss: () => ({}), size: 'medium', }, @@ -52,7 +53,7 @@ describe('useTag_unstable', () => { const { result } = renderHook(() => useTag_unstable({ dismissible: true }, ref), { wrapper: wrap() }); expect(result.current.dismissIcon).toBeDefined(); - expect(result.current.dismissIcon?.children).toBeDefined(); + expect(React.isValidElement(result.current.dismissIcon?.children)).toBe(true); }); it('should inherit appearance and size from TagGroupContext when not set on props', () => { diff --git a/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx index d6ba4f09ceeda4..61ccc24fa7f888 100644 --- a/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx +++ b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx @@ -4,8 +4,6 @@ import type { TabsterDOMAttribute } from '@fluentui/react-tabster'; import { useTagGroup_unstable, useTagGroupBase_unstable } from './useTagGroup'; -type RootRecord = Record; - describe('useTagGroup_unstable', () => { it('should default size to medium and appearance to filled', () => { const ref = React.createRef(); @@ -19,7 +17,7 @@ describe('useTagGroup_unstable', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagGroup_unstable({}, ref)); - expect((result.current.root as RootRecord)['data-tabster']).toEqual(expect.any(String)); + expect(result.current.root).toHaveProperty('data-tabster', expect.any(String)); }); it('should default role to toolbar', () => { @@ -42,7 +40,7 @@ describe('useTagGroupBase_unstable', () => { const arrowNavigationProps: TabsterDOMAttribute = { 'data-tabster': '{"mock":"value"}' }; const { result } = renderHook(() => useTagGroupBase_unstable({}, ref, { arrowNavigationProps })); - expect((result.current.root as RootRecord)['data-tabster']).toBe('{"mock":"value"}'); + expect(result.current.root).toHaveProperty('data-tabster', '{"mock":"value"}'); }); it('should call onAfterTagDismiss with the group container after a tag is dismissed', () => { @@ -58,26 +56,12 @@ describe('useTagGroupBase_unstable', () => { expect(onAfterTagDismiss).toHaveBeenCalledWith(null); }); - it('should NOT throw when onDismiss/onAfterTagDismiss are omitted (true headless mode)', () => { - const ref = React.createRef(); - const { result } = renderHook(() => useTagGroupBase_unstable({}, ref)); - - expect(() => result.current.handleTagDismiss({} as React.MouseEvent, { value: 'v1' })).not.toThrow(); - }); - it('should set aria-disabled on the root when disabled', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagGroupBase_unstable({ disabled: true }, ref)); expect(result.current.root['aria-disabled']).toBe(true); }); - it('should NOT expose design-only fields (size, appearance) on base state', () => { - const ref = React.createRef(); - const { result } = renderHook(() => useTagGroupBase_unstable({}, ref)); - expect(result.current).not.toHaveProperty('size'); - expect(result.current).not.toHaveProperty('appearance'); - }); - it('should provide handleTagSelect only when onTagSelect is supplied', () => { const ref = React.createRef(); const without = renderHook(() => useTagGroupBase_unstable({}, ref)); From f8af8bff6e9f38471ab6cc4ceab22a11fcfe7ee7 Mon Sep 17 00:00:00 2001 From: mainframev Date: Mon, 25 May 2026 14:43:31 +0200 Subject: [PATCH 8/8] test(react-tags): drop redundant 'no default dismissIcon children' base-hook assertion --- .../react-tags/library/src/components/Tag/useTag.test.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx b/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx index 08a883c4d5728d..8a86b72673f3f8 100644 --- a/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx +++ b/packages/react-components/react-tags/library/src/components/Tag/useTag.test.tsx @@ -84,14 +84,6 @@ describe('useTagBase_unstable', () => { expect(root.type).toBe('button'); }); - it('should NOT inject a default dismissIcon children (icon injection lives in the styled hook)', () => { - const ref = React.createRef(); - const { result } = renderHook(() => useTagBase_unstable({ dismissible: true }, ref), { wrapper: wrap() }); - - expect(result.current.dismissIcon).toBeDefined(); - expect(result.current.dismissIcon).not.toHaveProperty('children'); - }); - it('should set aria-selected when TagGroupContext role is listbox', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagBase_unstable({ selected: true }, ref), {