Skip to content

Commit f368d08

Browse files
mainframevclaude
andcommitted
test(react-tags): add hook regression tests for Tag family base + styled hooks
Add renderHook-based regression tests covering useTagBase/useTag, useInteractionTagBase/useInteractionTag, useInteractionTagPrimaryBase/ useInteractionTagPrimary, useInteractionTagSecondaryBase/useInteractionTagSecondary, and useTagGroupBase/useTagGroup. Locks in the public state shape so future base-hook refactors (such as the PR that decoupled TagGroup from Tabster) cannot silently regress the styled hooks: assertions cover slot element type via root.type, event handler presence/absence, ARIA attribute synthesis, context inheritance, DismissRegular default-children injection happening only in styled hooks, and the new arrowNavigationProps/onAfterTagDismiss options. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 24a9f85 commit f368d08

5 files changed

Lines changed: 482 additions & 13 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import * as React from 'react';
3+
4+
import { TagGroupContextProvider } from '../../contexts/tagGroupContext';
5+
import { useInteractionTag_unstable, useInteractionTagBase_unstable } from './useInteractionTag';
6+
7+
const wrap = (
8+
contextOverrides: Parameters<typeof TagGroupContextProvider>[0]['value'] = {
9+
handleTagDismiss: () => ({}),
10+
size: 'medium',
11+
},
12+
): React.FC<{ children?: React.ReactNode }> => {
13+
const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
14+
<TagGroupContextProvider value={contextOverrides}>{children}</TagGroupContextProvider>
15+
);
16+
return Wrapper;
17+
};
18+
19+
describe('useInteractionTag_unstable', () => {
20+
it('should add design-only fields (appearance, shape, size) on top of the base state', () => {
21+
const ref = React.createRef<HTMLDivElement>();
22+
const { result } = renderHook(
23+
() => useInteractionTag_unstable({ appearance: 'outline', shape: 'circular', size: 'small' }, ref),
24+
{
25+
wrapper: wrap(),
26+
},
27+
);
28+
29+
expect(result.current.appearance).toBe('outline');
30+
expect(result.current.shape).toBe('circular');
31+
expect(result.current.size).toBe('small');
32+
});
33+
34+
it('should default appearance to filled and shape to rounded', () => {
35+
const ref = React.createRef<HTMLDivElement>();
36+
const { result } = renderHook(() => useInteractionTag_unstable({}, ref), { wrapper: wrap() });
37+
38+
expect(result.current.appearance).toBe('filled');
39+
expect(result.current.shape).toBe('rounded');
40+
});
41+
42+
it('should inherit appearance and size from TagGroupContext when not set on props', () => {
43+
const ref = React.createRef<HTMLDivElement>();
44+
const { result } = renderHook(() => useInteractionTag_unstable({}, ref), {
45+
wrapper: wrap({ handleTagDismiss: () => ({}), size: 'extra-small', appearance: 'brand' }),
46+
});
47+
48+
expect(result.current.appearance).toBe('brand');
49+
expect(result.current.size).toBe('extra-small');
50+
});
51+
});
52+
53+
describe('useInteractionTagBase_unstable', () => {
54+
it('should NOT expose design-only fields (appearance/shape/size) on base state', () => {
55+
const ref = React.createRef<HTMLDivElement>();
56+
const { result } = renderHook(() => useInteractionTagBase_unstable({}, ref), { wrapper: wrap() });
57+
58+
expect((result.current as unknown as { appearance?: unknown }).appearance).toBeUndefined();
59+
expect((result.current as unknown as { shape?: unknown }).shape).toBeUndefined();
60+
expect((result.current as unknown as { size?: unknown }).size).toBeUndefined();
61+
});
62+
63+
it('should force disabled when TagGroupContext.disabled is true regardless of props', () => {
64+
const ref = React.createRef<HTMLDivElement>();
65+
const { result } = renderHook(() => useInteractionTagBase_unstable({ disabled: false }, ref), {
66+
wrapper: wrap({ handleTagDismiss: () => ({}), size: 'medium', disabled: true }),
67+
});
68+
expect(result.current.disabled).toBe(true);
69+
});
70+
71+
it('should derive selected from props OR context.selectedValues containing the tag value', () => {
72+
const ref = React.createRef<HTMLDivElement>();
73+
74+
const propSelected = renderHook(() => useInteractionTagBase_unstable({ selected: true, value: 'a' }, ref), {
75+
wrapper: wrap(),
76+
});
77+
expect(propSelected.result.current.selected).toBe(true);
78+
79+
const contextSelected = renderHook(() => useInteractionTagBase_unstable({ value: 'a' }, ref), {
80+
wrapper: wrap({ handleTagDismiss: () => ({}), size: 'medium', selectedValues: ['a'] }),
81+
});
82+
expect(contextSelected.result.current.selected).toBe(true);
83+
84+
const notSelected = renderHook(() => useInteractionTagBase_unstable({ value: 'b' }, ref), {
85+
wrapper: wrap({ handleTagDismiss: () => ({}), size: 'medium', selectedValues: ['a'] }),
86+
});
87+
expect(notSelected.result.current.selected).toBe(false);
88+
});
89+
90+
it('should generate interactionTagPrimaryId for use by aria-labelledby', () => {
91+
const ref = React.createRef<HTMLDivElement>();
92+
const { result } = renderHook(() => useInteractionTagBase_unstable({}, ref), { wrapper: wrap() });
93+
94+
expect(result.current.interactionTagPrimaryId).toEqual(expect.stringMatching(/^fui-InteractionTagPrimary-/));
95+
});
96+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import * as React from 'react';
3+
4+
import { InteractionTagContextProvider } from '../../contexts/interactionTagContext';
5+
import { useInteractionTagPrimary_unstable, useInteractionTagPrimaryBase_unstable } from './useInteractionTagPrimary';
6+
7+
const baseContext = {
8+
appearance: 'filled' as const,
9+
disabled: false,
10+
handleTagDismiss: () => ({}),
11+
interactionTagPrimaryId: 'fui-InteractionTagPrimary-_test_',
12+
selected: false,
13+
selectedValues: [],
14+
shape: 'rounded' as const,
15+
size: 'medium' as const,
16+
value: 'test',
17+
};
18+
19+
const wrap = (
20+
overrides: Partial<Parameters<typeof InteractionTagContextProvider>[0]['value']> = {},
21+
): React.FC<{ children?: React.ReactNode }> => {
22+
const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
23+
<InteractionTagContextProvider value={{ ...baseContext, ...overrides }}>{children}</InteractionTagContextProvider>
24+
);
25+
return Wrapper;
26+
};
27+
28+
describe('useInteractionTagPrimary_unstable', () => {
29+
it('should add design-only fields (appearance, shape, size, avatar*) on top of the base state', () => {
30+
const ref = React.createRef<HTMLButtonElement>();
31+
const { result } = renderHook(() => useInteractionTagPrimary_unstable({}, ref), {
32+
wrapper: wrap({ appearance: 'brand', shape: 'circular', size: 'small' }),
33+
});
34+
35+
expect(result.current.appearance).toBe('brand');
36+
expect(result.current.shape).toBe('circular');
37+
expect(result.current.size).toBe('small');
38+
expect(result.current.avatarShape).toBe('circular');
39+
expect(result.current.avatarSize).toBe(20);
40+
});
41+
});
42+
43+
describe('useInteractionTagPrimaryBase_unstable', () => {
44+
it('should render root with the interactionTagPrimaryId from context', () => {
45+
const ref = React.createRef<HTMLButtonElement>();
46+
const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { wrapper: wrap() });
47+
expect(result.current.root.id).toBe('fui-InteractionTagPrimary-_test_');
48+
});
49+
50+
it('should NOT expose design-only fields (appearance/shape/size/avatar*) on base state', () => {
51+
const ref = React.createRef<HTMLButtonElement>();
52+
const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { wrapper: wrap() });
53+
54+
expect((result.current as unknown as { appearance?: unknown }).appearance).toBeUndefined();
55+
expect((result.current as unknown as { shape?: unknown }).shape).toBeUndefined();
56+
expect((result.current as unknown as { size?: unknown }).size).toBeUndefined();
57+
expect((result.current as unknown as { avatarShape?: unknown }).avatarShape).toBeUndefined();
58+
expect((result.current as unknown as { avatarSize?: unknown }).avatarSize).toBeUndefined();
59+
});
60+
61+
it('should set aria-pressed when context has handleTagSelect (selectable group)', () => {
62+
const ref = React.createRef<HTMLButtonElement>();
63+
const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), {
64+
wrapper: wrap({ selected: true, handleTagSelect: () => ({}) }),
65+
});
66+
expect(result.current.root['aria-pressed']).toBe(true);
67+
});
68+
69+
it('should NOT set aria-pressed when context has no handleTagSelect', () => {
70+
const ref = React.createRef<HTMLButtonElement>();
71+
const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), {
72+
wrapper: wrap({ selected: true, handleTagSelect: undefined }),
73+
});
74+
expect(result.current.root['aria-pressed']).toBeUndefined();
75+
});
76+
77+
it('should default hasSecondaryAction to false', () => {
78+
const ref = React.createRef<HTMLButtonElement>();
79+
const { result } = renderHook(() => useInteractionTagPrimaryBase_unstable({}, ref), { wrapper: wrap() });
80+
expect(result.current.hasSecondaryAction).toBe(false);
81+
});
82+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import * as React from 'react';
3+
4+
import { InteractionTagContextProvider } from '../../contexts/interactionTagContext';
5+
import {
6+
useInteractionTagSecondary_unstable,
7+
useInteractionTagSecondaryBase_unstable,
8+
} from './useInteractionTagSecondary';
9+
10+
const baseContext = {
11+
appearance: 'filled' as const,
12+
disabled: false,
13+
handleTagDismiss: () => ({}),
14+
interactionTagPrimaryId: 'fui-InteractionTagPrimary-_test_',
15+
selected: false,
16+
selectedValues: [],
17+
shape: 'rounded' as const,
18+
size: 'medium' as const,
19+
value: 'test',
20+
};
21+
22+
const wrap = (
23+
overrides: Partial<Parameters<typeof InteractionTagContextProvider>[0]['value']> = {},
24+
): React.FC<{ children?: React.ReactNode }> => {
25+
const Wrapper: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
26+
<InteractionTagContextProvider value={{ ...baseContext, ...overrides }}>{children}</InteractionTagContextProvider>
27+
);
28+
return Wrapper;
29+
};
30+
31+
describe('useInteractionTagSecondary_unstable', () => {
32+
it('should inject DismissRegular as default root children', () => {
33+
const ref = React.createRef<HTMLButtonElement>();
34+
const { result } = renderHook(() => useInteractionTagSecondary_unstable({}, ref), { wrapper: wrap() });
35+
expect(result.current.root.children).toBeDefined();
36+
});
37+
38+
it('should preserve user-provided children instead of the default DismissRegular', () => {
39+
const ref = React.createRef<HTMLButtonElement>();
40+
const { result } = renderHook(() => useInteractionTagSecondary_unstable({ children: 'X' }, ref), {
41+
wrapper: wrap(),
42+
});
43+
expect(result.current.root.children).toBe('X');
44+
});
45+
46+
it('should inherit appearance/shape/size from context', () => {
47+
const ref = React.createRef<HTMLButtonElement>();
48+
const { result } = renderHook(() => useInteractionTagSecondary_unstable({}, ref), {
49+
wrapper: wrap({ appearance: 'outline', shape: 'circular', size: 'small' }),
50+
});
51+
expect(result.current.appearance).toBe('outline');
52+
expect(result.current.shape).toBe('circular');
53+
expect(result.current.size).toBe('small');
54+
});
55+
});
56+
57+
describe('useInteractionTagSecondaryBase_unstable', () => {
58+
it('should render root with type="button"', () => {
59+
const ref = React.createRef<HTMLButtonElement>();
60+
const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() });
61+
expect(result.current.root.type).toBe('button');
62+
});
63+
64+
it('should NOT inject DismissRegular children by default (icon injection lives in the styled hook)', () => {
65+
const ref = React.createRef<HTMLButtonElement>();
66+
const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() });
67+
expect(result.current.root.children).toBeUndefined();
68+
});
69+
70+
it('should attach onClick and onKeyDown handlers', () => {
71+
const ref = React.createRef<HTMLButtonElement>();
72+
const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() });
73+
expect(result.current.root.onClick).toBeDefined();
74+
expect(result.current.root.onKeyDown).toBeDefined();
75+
});
76+
77+
it('should build aria-labelledby from interactionTagPrimaryId and own id', () => {
78+
const ref = React.createRef<HTMLButtonElement>();
79+
const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() });
80+
expect(result.current.root['aria-labelledby']).toEqual(
81+
expect.stringMatching(/^fui-InteractionTagPrimary-_test_ fui-InteractionTagSecondary-/),
82+
);
83+
});
84+
85+
it('should NOT expose design-only fields (appearance/shape/size)', () => {
86+
const ref = React.createRef<HTMLButtonElement>();
87+
const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), { wrapper: wrap() });
88+
expect((result.current as unknown as { appearance?: unknown }).appearance).toBeUndefined();
89+
expect((result.current as unknown as { shape?: unknown }).shape).toBeUndefined();
90+
expect((result.current as unknown as { size?: unknown }).size).toBeUndefined();
91+
});
92+
93+
it('should call handleTagDismiss on Delete/Backspace keyDown via context', () => {
94+
const handleTagDismiss = jest.fn();
95+
const ref = React.createRef<HTMLButtonElement>();
96+
const { result } = renderHook(() => useInteractionTagSecondaryBase_unstable({}, ref), {
97+
wrapper: wrap({ handleTagDismiss, value: 'val' }),
98+
});
99+
100+
const event = { key: 'Delete', defaultPrevented: false } as unknown as React.KeyboardEvent<HTMLButtonElement>;
101+
result.current.root.onKeyDown?.(event);
102+
103+
expect(handleTagDismiss).toHaveBeenCalledWith(event, { value: 'val' });
104+
});
105+
});

0 commit comments

Comments
 (0)