From f1a66a184f8ec5d9c9fb4ec45ec87e429dcea94b Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 19 May 2026 14:28:23 +0200 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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)); }); });