Skip to content

Commit 6599bf0

Browse files
authored
refactor(react-card): make base hooks tabster-free (#36004)
1 parent 0f870a4 commit 6599bf0

7 files changed

Lines changed: 260 additions & 84 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "Make Card base hooks tabster-free; expose shouldRestrictTriggerAction on CardBaseProps",
4+
"packageName": "@fluentui/react-card",
5+
"email": "dmytrokirpa@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-card/library/etc/react-card.api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import type { SlotClassNames } from '@fluentui/react-utilities';
1616
export const Card: ForwardRefComponent<CardProps>;
1717

1818
// @public (undocumented)
19-
export type CardBaseProps = Omit<CardProps, 'appearance' | 'orientation' | 'size'>;
19+
export type CardBaseProps = Omit<CardProps, 'appearance' | 'orientation' | 'size'> & {
20+
shouldRestrictTriggerAction?: (event: CardOnSelectionChangeEvent) => boolean;
21+
};
2022

2123
// @public (undocumented)
2224
export type CardBaseState = Omit<CardState, 'appearance' | 'orientation' | 'size'>;

packages/react-components/react-card/library/src/components/Card/Card.types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,12 @@ export type CardProps = ComponentProps<CardSlots> & {
135135
disabled?: boolean;
136136
};
137137

138-
export type CardBaseProps = Omit<CardProps, 'appearance' | 'orientation' | 'size'>;
138+
export type CardBaseProps = Omit<CardProps, 'appearance' | 'orientation' | 'size'> & {
139+
/**
140+
* Predicate function to determine whether the card's selection action should be restricted.
141+
*/
142+
shouldRestrictTriggerAction?: (event: CardOnSelectionChangeEvent) => boolean;
143+
};
139144

140145
/**
141146
* State used in rendering Card.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import * as React from 'react';
2+
import { renderHook } from '@testing-library/react-hooks';
3+
import { useFocusableGroup, useFocusFinders, useFocusWithin } from '@fluentui/react-tabster';
4+
5+
import { useCard_unstable, useCardBase_unstable } from './useCard';
6+
7+
jest.mock('@fluentui/react-tabster', () => ({
8+
useFocusWithin: jest.fn(),
9+
useFocusFinders: jest.fn(),
10+
useFocusableGroup: jest.fn(),
11+
}));
12+
13+
const mockFocusWithinRef = React.createRef<HTMLDivElement>();
14+
const mockFindAllFocusable = jest.fn().mockReturnValue([]);
15+
16+
beforeEach(() => {
17+
(useFocusWithin as jest.Mock).mockReturnValue(mockFocusWithinRef);
18+
(useFocusFinders as jest.Mock).mockReturnValue({ findAllFocusable: mockFindAllFocusable });
19+
(useFocusableGroup as jest.Mock).mockReturnValue({ 'data-tabster': '{"groupper":{}}' });
20+
mockFindAllFocusable.mockReturnValue([]);
21+
});
22+
23+
// ---------------------------------------------------------------------------
24+
// useCardBase_unstable — interactive is now computed from event props only,
25+
// without any @fluentui/react-tabster dependency.
26+
// ---------------------------------------------------------------------------
27+
28+
describe('useCardBase_unstable', () => {
29+
it('returns interactive: false when no pointer/mouse event props are provided', () => {
30+
const { result } = renderHook(() => useCardBase_unstable({}, React.createRef()));
31+
expect(result.current.interactive).toBe(false);
32+
});
33+
34+
it('returns interactive: true when onClick is provided', () => {
35+
const { result } = renderHook(() => useCardBase_unstable({ onClick: jest.fn() }, React.createRef()));
36+
expect(result.current.interactive).toBe(true);
37+
});
38+
39+
it('returns interactive: true for other pointer event props', () => {
40+
const { result: r1 } = renderHook(() => useCardBase_unstable({ onPointerDown: jest.fn() }, React.createRef()));
41+
expect(r1.current.interactive).toBe(true);
42+
43+
const { result: r2 } = renderHook(() => useCardBase_unstable({ onMouseUp: jest.fn() }, React.createRef()));
44+
expect(r2.current.interactive).toBe(true);
45+
});
46+
47+
it('returns interactive: false when disabled even if onClick is provided', () => {
48+
const { result } = renderHook(() =>
49+
useCardBase_unstable({ onClick: jest.fn(), disabled: true }, React.createRef()),
50+
);
51+
expect(result.current.interactive).toBe(false);
52+
});
53+
});
54+
55+
// ---------------------------------------------------------------------------
56+
// useCard_unstable — focus management (tabIndex, focusable-group attrs) moved
57+
// here from useCardBase_unstable; applied only when appropriate.
58+
// ---------------------------------------------------------------------------
59+
60+
describe('useCard_unstable', () => {
61+
it('applies tabIndex: 0 and focusable-group attrs when interactive and not selectable', () => {
62+
const { result } = renderHook(() => useCard_unstable({ onClick: jest.fn() }, React.createRef()));
63+
expect(result.current.root.tabIndex).toBe(0);
64+
expect((result.current.root as Record<string, unknown>)['data-tabster']).toBe('{"groupper":{}}');
65+
});
66+
67+
it('does not apply focus attrs when disabled', () => {
68+
const { result } = renderHook(() => useCard_unstable({ onClick: jest.fn(), disabled: true }, React.createRef()));
69+
expect(result.current.root.tabIndex).toBeUndefined();
70+
expect((result.current.root as Record<string, unknown>)['data-tabster']).toBeUndefined();
71+
});
72+
73+
it('does not apply focus attrs when focusMode is off', () => {
74+
const { result } = renderHook(() => useCard_unstable({ onClick: jest.fn(), focusMode: 'off' }, React.createRef()));
75+
expect(result.current.root.tabIndex).toBeUndefined();
76+
expect((result.current.root as Record<string, unknown>)['data-tabster']).toBeUndefined();
77+
});
78+
79+
it('does not apply focus attrs when card is selectable', () => {
80+
const { result } = renderHook(() => useCard_unstable({ onSelectionChange: jest.fn() }, React.createRef()));
81+
expect(result.current.root.tabIndex).toBeUndefined();
82+
expect((result.current.root as Record<string, unknown>)['data-tabster']).toBeUndefined();
83+
});
84+
});

packages/react-components/react-card/library/src/components/Card/useCard.ts

Lines changed: 76 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
import * as React from 'react';
44
import { getIntrinsicElementProps, useMergedRefs, slot } from '@fluentui/react-utilities';
5-
import { useFocusableGroup, useFocusWithin } from '@fluentui/react-tabster';
5+
import { useFocusableGroup, useFocusFinders, useFocusWithin } from '@fluentui/react-tabster';
66

7-
import type { CardBaseProps, CardBaseState, CardProps, CardState } from './Card.types';
7+
import type { CardBaseProps, CardBaseState, CardOnSelectionChangeEvent, CardProps, CardState } from './Card.types';
88
import { useCardSelectable } from './useCardSelectable';
99
import { cardContextDefaultValue } from './CardContext';
1010

@@ -15,58 +15,30 @@ const focusMap = {
1515
'tab-only': 'unlimited',
1616
} as const;
1717

18+
const interactiveEventProps = [
19+
'onClick',
20+
'onDoubleClick',
21+
'onMouseUp',
22+
'onMouseDown',
23+
'onPointerUp',
24+
'onPointerDown',
25+
'onTouchStart',
26+
'onTouchEnd',
27+
'onDragStart',
28+
'onDragEnd',
29+
] as (keyof React.HTMLAttributes<HTMLElement>)[];
30+
1831
/**
19-
* Create the state for interactive cards.
20-
*
21-
* This internal hook defines if the card is interactive
22-
* and control focus properties based on that.
23-
*
24-
* @param props - props from this instance of Card
32+
* Compute whether a Card is interactive based on the presence of pointer/mouse
33+
* event props and the disabled flag. This intentionally does not depend on
34+
* focus management utilities so it can be used from headless contexts.
2535
*/
26-
const useCardInteractive = ({ focusMode: initialFocusMode, disabled = false, ...props }: CardProps) => {
27-
const interactive = (
28-
[
29-
'onClick',
30-
'onDoubleClick',
31-
'onMouseUp',
32-
'onMouseDown',
33-
'onPointerUp',
34-
'onPointerDown',
35-
'onTouchStart',
36-
'onTouchEnd',
37-
'onDragStart',
38-
'onDragEnd',
39-
] as (keyof React.HTMLAttributes<HTMLElement>)[]
40-
).some(prop => props[prop]);
41-
42-
// default focusMode to tab-only when interactive, and off when not
43-
const focusMode = initialFocusMode ?? (interactive ? 'no-tab' : 'off');
44-
45-
const groupperAttrs = useFocusableGroup({
46-
tabBehavior: focusMap[focusMode],
47-
});
48-
49-
if (disabled) {
50-
return {
51-
interactive: false,
52-
focusAttributes: null,
53-
};
54-
}
55-
56-
if (focusMode === 'off') {
57-
return {
58-
interactive,
59-
focusAttributes: null,
60-
};
36+
const computeInteractive = (props: CardProps): boolean => {
37+
if (props.disabled) {
38+
return false;
6139
}
6240

63-
return {
64-
interactive,
65-
focusAttributes: {
66-
...groupperAttrs,
67-
tabIndex: 0,
68-
},
69-
};
41+
return interactiveEventProps.some(prop => props[prop] !== undefined);
7042
};
7143

7244
/**
@@ -80,7 +52,50 @@ const useCardInteractive = ({ focusMode: initialFocusMode, disabled = false, ...
8052
*/
8153
export const useCard_unstable = (props: CardProps, ref: React.Ref<HTMLDivElement>): CardState => {
8254
const { appearance = 'filled', orientation = 'vertical', size = 'medium', ...cardProps } = props;
83-
const state = useCardBase_unstable(cardProps, ref);
55+
const { disabled = false, focusMode: focusModeProp } = props;
56+
57+
// Focus-within ref drives the styled focus outline; merged with the user ref
58+
// before being passed down so the base hook does not depend on react-tabster.
59+
const focusWithinRef = useFocusWithin<HTMLDivElement>();
60+
const cardRef = useMergedRefs(focusWithinRef, ref);
61+
62+
// Focus-aware predicate that prevents toggling the selection when the user
63+
// interacts with an inner focusable element.
64+
const { findAllFocusable } = useFocusFinders();
65+
const shouldRestrictTriggerAction = React.useCallback(
66+
(event: CardOnSelectionChangeEvent) => {
67+
if (!focusWithinRef.current) {
68+
return false;
69+
}
70+
71+
const focusableElements = findAllFocusable(focusWithinRef.current);
72+
const target = event.target as HTMLElement;
73+
74+
return focusableElements.some(element => element.contains(target));
75+
},
76+
[findAllFocusable, focusWithinRef],
77+
);
78+
79+
const interactive = computeInteractive(props);
80+
const focusMode = focusModeProp ?? (interactive ? 'no-tab' : 'off');
81+
const groupperAttrs = useFocusableGroup({
82+
tabBehavior: focusMap[focusMode],
83+
});
84+
85+
const state = useCardBase_unstable(
86+
{
87+
shouldRestrictTriggerAction,
88+
...cardProps,
89+
},
90+
cardRef,
91+
);
92+
93+
// Apply focusable-group attributes only when the card is not selectable, not
94+
// disabled and the focus mode is enabled.
95+
const shouldApplyFocusAttributes = !disabled && !state.selectable && focusMode !== 'off';
96+
if (shouldApplyFocusAttributes) {
97+
Object.assign(state.root, groupperAttrs, { tabIndex: 0 });
98+
}
8499

85100
return {
86101
...state,
@@ -92,27 +107,29 @@ export const useCard_unstable = (props: CardProps, ref: React.Ref<HTMLDivElement
92107

93108
/**
94109
* Base hook for Card component, which manages state related to interactivity, selection,
95-
* focus management, ARIA attributes, and slot structure without design props.
110+
* ARIA attributes, and slot structure without design props or focus management.
111+
*
112+
* This hook is intentionally free of `@fluentui/react-tabster` so that it can be
113+
* consumed by headless component packages. Focus management (focusable group
114+
* attributes, focus-within, focus-restriction predicate) is layered on top in
115+
* `useCard_unstable`.
96116
*
97117
* @param props - props from this instance of Card
98118
* @param ref - reference to the root element of Card
119+
* @param options - optional behavior overrides such as a focus-aware restriction predicate
99120
*/
100121
export const useCardBase_unstable = (props: CardBaseProps, ref: React.Ref<HTMLDivElement>): CardBaseState => {
101122
const { disabled = false, ...restProps } = props;
102123

103124
const [referenceId, setReferenceId] = React.useState(cardContextDefaultValue.selectableA11yProps.referenceId);
104125
const [referenceLabel, setReferenceLabel] = React.useState(cardContextDefaultValue.selectableA11yProps.referenceId);
105126

106-
const cardBaseRef = useFocusWithin<HTMLDivElement>();
107127
const { selectable, selected, selectableCardProps, selectFocused, checkboxSlot, floatingActionSlot } =
108-
useCardSelectable(props, { referenceId, referenceLabel }, cardBaseRef);
109-
110-
const cardRef = useMergedRefs(cardBaseRef, ref);
128+
useCardSelectable(props, { referenceId, referenceLabel });
111129

112-
const { interactive, focusAttributes } = useCardInteractive(props);
130+
const interactive = computeInteractive(props);
113131

114132
let cardRootProps = {
115-
...(!selectable ? focusAttributes : null),
116133
...restProps,
117134
...selectableCardProps,
118135
};
@@ -146,7 +163,7 @@ export const useCardBase_unstable = (props: CardBaseProps, ref: React.Ref<HTMLDi
146163

147164
root: slot.always(
148165
getIntrinsicElementProps('div', {
149-
ref: cardRef,
166+
ref,
150167
role: 'group',
151168
...cardRootProps,
152169
}),
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type * as React from 'react';
2+
import { renderHook, act } from '@testing-library/react-hooks';
3+
4+
import { useCardSelectable } from './useCardSelectable';
5+
6+
const makeA11yProps = () => ({ referenceId: undefined, referenceLabel: undefined });
7+
8+
// useCardSelectable — shouldRestrictTriggerAction is a new optional predicate
9+
// on CardBaseProps; the checkbox target always bypasses it.
10+
11+
describe('useCardSelectable', () => {
12+
it('blocks selection when shouldRestrictTriggerAction returns true', () => {
13+
const onSelectionChange = jest.fn();
14+
const shouldRestrictTriggerAction = jest.fn().mockReturnValue(true);
15+
const { result } = renderHook(() =>
16+
useCardSelectable({ onSelectionChange, shouldRestrictTriggerAction }, makeA11yProps()),
17+
);
18+
19+
const event = { target: document.createElement('span') } as unknown as React.MouseEvent<HTMLDivElement>;
20+
act(() => {
21+
result.current.selectableCardProps!.onClick(event);
22+
});
23+
24+
expect(onSelectionChange).not.toHaveBeenCalled();
25+
expect(shouldRestrictTriggerAction).toHaveBeenCalledWith(event);
26+
});
27+
28+
it('allows selection when shouldRestrictTriggerAction returns false', () => {
29+
const onSelectionChange = jest.fn();
30+
const shouldRestrictTriggerAction = jest.fn().mockReturnValue(false);
31+
const { result } = renderHook(() =>
32+
useCardSelectable({ onSelectionChange, shouldRestrictTriggerAction }, makeA11yProps()),
33+
);
34+
35+
const event = { target: document.createElement('span') } as unknown as React.MouseEvent<HTMLDivElement>;
36+
act(() => {
37+
result.current.selectableCardProps!.onClick(event);
38+
});
39+
40+
expect(onSelectionChange).toHaveBeenCalledTimes(1);
41+
});
42+
43+
it('bypasses shouldRestrictTriggerAction when the checkbox itself is the event target', () => {
44+
const onSelectionChange = jest.fn();
45+
const shouldRestrictTriggerAction = jest.fn().mockReturnValue(true);
46+
const { result } = renderHook(() =>
47+
useCardSelectable({ onSelectionChange, shouldRestrictTriggerAction }, makeA11yProps()),
48+
);
49+
50+
// slot.optional spreads defaultProps directly into the result object.
51+
const checkboxSlot = result.current.checkboxSlot! as unknown as {
52+
ref: React.RefObject<HTMLInputElement>;
53+
onChange: React.ChangeEventHandler<HTMLInputElement>;
54+
};
55+
const checkboxEl = document.createElement('input');
56+
checkboxSlot.ref.current = checkboxEl;
57+
58+
const changeEvent = { target: checkboxEl } as unknown as React.ChangeEvent<HTMLInputElement>;
59+
act(() => {
60+
checkboxSlot.onChange(changeEvent);
61+
});
62+
63+
expect(shouldRestrictTriggerAction).not.toHaveBeenCalled();
64+
expect(onSelectionChange).toHaveBeenCalledTimes(1);
65+
});
66+
});

0 commit comments

Comments
 (0)