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..c39e67e9831c2c --- /dev/null +++ b/change/@fluentui-react-tags-80527846-5fc6-4b8e-adcc-b758379cefb2.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: export contexts for headless", + "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..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 @@ -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; @@ -137,6 +150,9 @@ export type TagBaseState = DistributiveOmit; +// @public (undocumented) +export type TagContextValues = TagAvatarContextValues; + // @public (undocumented) export type TagDismissData = { value: Value; @@ -160,6 +176,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 +250,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 +290,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/InteractionTag/useInteractionTag.test.tsx b/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx new file mode 100644 index 00000000000000..0a6a481a539b1c --- /dev/null +++ b/packages/react-components/react-tags/library/src/components/InteractionTag/useInteractionTag.test.tsx @@ -0,0 +1,88 @@ +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: TagGroupContextValue = { + 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 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..5f83eac3e0f581 --- /dev/null +++ b/packages/react-components/react-tags/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.test.tsx @@ -0,0 +1,70 @@ +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 = {}): 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 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..5408a8a3d39185 --- /dev/null +++ b/packages/react-components/react-tags/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.test.tsx @@ -0,0 +1,96 @@ +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 = {}): 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(React.isValidElement(result.current.root.children)).toBe(true); + }); + + 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 children by default', () => { + 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 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..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 @@ -2,7 +2,20 @@ import { renderHook } from '@testing-library/react-hooks'; import * as React from 'react'; import { TagGroupContextProvider } from '../../contexts/tagGroupContext'; -import { useTag_unstable } from './useTag'; +import type { TagGroupContextValue } from '../../contexts/tagGroupContext'; +import { useTag_unstable, useTagBase_unstable } from './useTag'; + +const wrap = ( + contextOverrides: TagGroupContextValue = { + 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 +24,98 @@ 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(React.isValidElement(result.current.dismissIcon?.children)).toBe(true); + }); + + 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 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..61ccc24fa7f888 --- /dev/null +++ b/packages/react-components/react-tags/library/src/components/TagGroup/useTagGroup.test.tsx @@ -0,0 +1,74 @@ +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'; + +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).toHaveProperty('data-tabster', 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).toHaveProperty('data-tabster', '{"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 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 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)); + }); +}); 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..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, @@ -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,