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..c406c2c8fb58e0 --- /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).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), { + 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..c825a501add539 --- /dev/null +++ b/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx @@ -0,0 +1,83 @@ +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: InteractionTagContextValue = { + appearance: 'filled', + disabled: false, + handleTagDismiss: () => ({}), + interactionTagPrimaryId: 'fui-InteractionTagPrimary-_test_', + selected: false, + selectedValues: [], + shape: 'rounded', + size: 'medium', + 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).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), { + 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).not.toHaveProperty('aria-pressed'); + }); + + 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..58f8d6185f2f41 --- /dev/null +++ b/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx @@ -0,0 +1,106 @@ +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: InteractionTagContextValue = { + appearance: 'filled', + disabled: false, + handleTagDismiss: () => ({}), + interactionTagPrimaryId: 'fui-InteractionTagPrimary-_test_', + selected: false, + selectedValues: [], + shape: 'rounded', + size: 'medium', + 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).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).toEqual(expect.any(Function)); + expect(result.current.root.onKeyDown).toEqual(expect.any(Function)); + }); + + 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).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(); + 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..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 @@ -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,106 @@ 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(); + expect(result.current.root.onClick).toEqual(expect.any(Function)); } else { - expect(result.current.root.onClick).toBeUndefined(); + expect(result.current.root).not.toHaveProperty('onClick'); } }); + + 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).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).toEqual(expect.any(Function)); + expect(root.onKeyDown).toEqual(expect.any(Function)); + 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), { + 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); + const root = result.current.root as React.ButtonHTMLAttributes; + expect(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); + 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 new file mode 100644 index 00000000000000..d6ba4f09ceeda4 --- /dev/null +++ b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx @@ -0,0 +1,90 @@ +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'; + +type RootRecord = Record; + +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)); + + expect((result.current.root as RootRecord)['data-tabster']).toEqual(expect.any(String)); + }); + + 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).not.toHaveProperty('data-tabster'); + }); + + it('should spread arrowNavigationProps option onto root when supplied', () => { + const ref = React.createRef(); + 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"}'); + }); + + 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' }); + 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)); + expect(without.result.current.handleTagSelect).toBe(undefined); + + const onTagSelect = jest.fn(); + const withSelect = renderHook(() => useTagGroupBase_unstable({ onTagSelect }, ref)); + expect(withSelect.result.current.handleTagSelect).toEqual(expect.any(Function)); + }); +});