Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: export contexts for headless",
"packageName": "@fluentui/react-tags",
"email": "vgenaev@gmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<InteractionTagProps>;
Expand All @@ -29,6 +30,18 @@ export type InteractionTagBaseState<Value = TagValue> = Omit<InteractionTagState
// @public (undocumented)
export const interactionTagClassNames: SlotClassNames<InteractionTagSlots>;

// @public (undocumented)
export const InteractionTagContextProvider: React_2.Provider<InteractionTagContextValue<string> | undefined>;

// @public
export type InteractionTagContextValue<Value = string> = Required<Pick<InteractionTagState, 'appearance' | 'disabled' | 'selected' | 'selectedValues' | 'shape' | 'size'> & {
handleTagDismiss: TagDismissHandler<Value>;
interactionTagPrimaryId: string;
value?: Value;
}> & {
handleTagSelect?: TagSelectHandler<Value>;
};

// @public
export const InteractionTagPrimary: ForwardRefComponent<InteractionTagPrimaryProps>;

Expand Down Expand Up @@ -137,6 +150,9 @@ export type TagBaseState = DistributiveOmit<TagState, 'appearance' | 'size' | 's
// @public (undocumented)
export const tagClassNames: SlotClassNames<TagSlots>;

// @public (undocumented)
export type TagContextValues = TagAvatarContextValues;

// @public (undocumented)
export type TagDismissData<Value = TagValue> = {
value: Value;
Expand All @@ -160,6 +176,12 @@ export type TagGroupBaseState<Value = TagValue> = Omit<TagGroupState<Value>, 'ap
// @public (undocumented)
export const tagGroupClassNames: SlotClassNames<TagGroupSlots>;

// @public (undocumented)
export const TagGroupContextProvider: React_2.Provider<TagGroupContextValue | undefined>;

// @public
export type TagGroupContextValue = Required<Pick<TagGroupState, 'handleTagDismiss' | 'size'>> & Partial<Pick<TagGroupState, 'disabled' | 'appearance' | 'dismissible' | 'handleTagSelect' | 'role' | 'selectedValues'>>;

// @public (undocumented)
export type TagGroupContextValues = {
tagGroup: TagGroupContextValue;
Expand Down Expand Up @@ -228,6 +250,9 @@ export const useInteractionTag_unstable: (props: InteractionTagProps, ref: React
// @public
export const useInteractionTagBase_unstable: (props: InteractionTagBaseProps, ref: React_2.Ref<HTMLDivElement>) => InteractionTagBaseState;

// @public (undocumented)
export const useInteractionTagContext_unstable: () => InteractionTagContextValue;

// @public (undocumented)
export function useInteractionTagContextValues_unstable(state: InteractionTagState): InteractionTagContextValues;

Expand Down Expand Up @@ -265,7 +290,10 @@ export const useTagBase_unstable: (props: TagBaseProps, ref: React_2.Ref<HTMLSpa
export const useTagGroup_unstable: (props: TagGroupProps, ref: React_2.Ref<HTMLDivElement>) => TagGroupState;

// @public
export const useTagGroupBase_unstable: (props: TagGroupBaseProps, ref: React_2.Ref<HTMLDivElement>) => TagGroupBaseState;
export const useTagGroupBase_unstable: (props: TagGroupBaseProps, ref: React_2.Ref<HTMLDivElement>, options?: UseTagGroupBaseOptions) => TagGroupBaseState;

// @public (undocumented)
export const useTagGroupContext_unstable: () => TagGroupContextValue;

// @public (undocumented)
export function useTagGroupContextValues_unstable(state: TagGroupState): TagGroupContextValues;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => (
<TagGroupContextProvider value={contextOverrides}>{children}</TagGroupContextProvider>
);
return Wrapper;
};

describe('useInteractionTag_unstable', () => {
it('should add design-only fields (appearance, shape, size) on top of the base state', () => {
const ref = React.createRef<HTMLDivElement>();
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<HTMLDivElement>();
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<HTMLDivElement>();
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<HTMLDivElement>();
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<HTMLDivElement>();

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<HTMLDivElement>();
const { result } = renderHook(() => useInteractionTagBase_unstable({}, ref), { wrapper: wrap() });

expect(result.current.interactionTagPrimaryId).toEqual(expect.stringMatching(/^fui-InteractionTagPrimary-/));
});
});
Original file line number Diff line number Diff line change
@@ -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<InteractionTagContextValue> = {}): React.FC<{ children?: React.ReactNode }> => {
const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
<InteractionTagContextProvider value={{ ...baseContext, ...overrides }}>{children}</InteractionTagContextProvider>
);
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<HTMLButtonElement>();
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<HTMLButtonElement>();
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<HTMLButtonElement>();
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<HTMLButtonElement>();
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<HTMLButtonElement>();
const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { wrapper: wrap() });
expect(result.current.hasSecondaryAction).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -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<InteractionTagContextValue> = {}): React.FC<{ children?: React.ReactNode }> => {
const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
<InteractionTagContextProvider value={{ ...baseContext, ...overrides }}>{children}</InteractionTagContextProvider>
);
return Wrapper;
};

describe('useInteractionTagSecondary_unstable', () => {
it('should inject DismissRegular as default root children', () => {
const ref = React.createRef<HTMLButtonElement>();
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<HTMLButtonElement>();
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<HTMLButtonElement>();
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<HTMLButtonElement>();
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<HTMLButtonElement>();
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<HTMLButtonElement>();
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<HTMLButtonElement>();
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<HTMLButtonElement>();
const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), {
wrapper: wrap({ handleTagDismiss, value: 'val' }),
});

const event = { key: 'Delete', defaultPrevented: false } as unknown as React.KeyboardEvent<HTMLButtonElement>;
result.current.root.onKeyDown?.(event);

expect(handleTagDismiss).toHaveBeenCalledWith(event, { value: 'val' });
});
});
Loading
Loading