Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
Copy link
Copy Markdown

@github-actions github-actions Bot Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Charts-DonutChart 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Charts-DonutChart.Dynamic.default.chromium.png 5581 Changed
vr-tests-react-components/Charts-DonutChart.Dynamic - Dark Mode.default.chromium.png 7530 Changed
vr-tests-react-components/Charts-DonutChart.Dynamic - RTL.default.chromium.png 5570 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.chromium.png 880 Changed
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 129 Changed
vr-tests-react-components/ProgressBar converged 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 59 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 27 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 114 Changed
vr-tests-react-components/TagPicker 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - Dark Mode.disabled input hover.chromium.png 658 Changed
vr-tests-react-components/TagPicker.disabled - RTL.chromium.png 635 Changed
vr-tests-react-components/TagPicker.disabled.disabled input hover.chromium.png 677 Changed

There were 3 duplicate changes discarded. Check the build logs for more information.

"type": "minor",
"comment": "Make Card base hooks tabster-free; expose shouldRestrictTriggerAction on CardBaseProps",
"packageName": "@fluentui/react-card",
"email": "dmytrokirpa@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import type { SlotClassNames } from '@fluentui/react-utilities';
export const Card: ForwardRefComponent<CardProps>;

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

// @public (undocumented)
export type CardBaseState = Omit<CardState, 'appearance' | 'orientation' | 'size'>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,12 @@ export type CardProps = ComponentProps<CardSlots> & {
disabled?: boolean;
};

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

/**
* State used in rendering Card.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import * as React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { useFocusableGroup, useFocusFinders, useFocusWithin } from '@fluentui/react-tabster';

import { useCard_unstable, useCardBase_unstable } from './useCard';

jest.mock('@fluentui/react-tabster', () => ({
useFocusWithin: jest.fn(),
useFocusFinders: jest.fn(),
useFocusableGroup: jest.fn(),
}));

const mockFocusWithinRef = React.createRef<HTMLDivElement>();
const mockFindAllFocusable = jest.fn().mockReturnValue([]);

beforeEach(() => {
(useFocusWithin as jest.Mock).mockReturnValue(mockFocusWithinRef);
(useFocusFinders as jest.Mock).mockReturnValue({ findAllFocusable: mockFindAllFocusable });
(useFocusableGroup as jest.Mock).mockReturnValue({ 'data-tabster': '{"groupper":{}}' });
mockFindAllFocusable.mockReturnValue([]);
});

// ---------------------------------------------------------------------------
// useCardBase_unstable — interactive is now computed from event props only,
// without any @fluentui/react-tabster dependency.
// ---------------------------------------------------------------------------

describe('useCardBase_unstable', () => {
it('returns interactive: false when no pointer/mouse event props are provided', () => {
const { result } = renderHook(() => useCardBase_unstable({}, React.createRef()));
expect(result.current.interactive).toBe(false);
});

it('returns interactive: true when onClick is provided', () => {
const { result } = renderHook(() => useCardBase_unstable({ onClick: jest.fn() }, React.createRef()));
expect(result.current.interactive).toBe(true);
});

it('returns interactive: true for other pointer event props', () => {
const { result: r1 } = renderHook(() => useCardBase_unstable({ onPointerDown: jest.fn() }, React.createRef()));
expect(r1.current.interactive).toBe(true);

const { result: r2 } = renderHook(() => useCardBase_unstable({ onMouseUp: jest.fn() }, React.createRef()));
expect(r2.current.interactive).toBe(true);
});

it('returns interactive: false when disabled even if onClick is provided', () => {
const { result } = renderHook(() =>
useCardBase_unstable({ onClick: jest.fn(), disabled: true }, React.createRef()),
);
expect(result.current.interactive).toBe(false);
});
});

// ---------------------------------------------------------------------------
// useCard_unstable — focus management (tabIndex, focusable-group attrs) moved
// here from useCardBase_unstable; applied only when appropriate.
// ---------------------------------------------------------------------------

describe('useCard_unstable', () => {
it('applies tabIndex: 0 and focusable-group attrs when interactive and not selectable', () => {
const { result } = renderHook(() => useCard_unstable({ onClick: jest.fn() }, React.createRef()));
expect(result.current.root.tabIndex).toBe(0);
expect((result.current.root as Record<string, unknown>)['data-tabster']).toBe('{"groupper":{}}');
});

it('does not apply focus attrs when disabled', () => {
const { result } = renderHook(() => useCard_unstable({ onClick: jest.fn(), disabled: true }, React.createRef()));
expect(result.current.root.tabIndex).toBeUndefined();
expect((result.current.root as Record<string, unknown>)['data-tabster']).toBeUndefined();
});

it('does not apply focus attrs when focusMode is off', () => {
const { result } = renderHook(() => useCard_unstable({ onClick: jest.fn(), focusMode: 'off' }, React.createRef()));
expect(result.current.root.tabIndex).toBeUndefined();
expect((result.current.root as Record<string, unknown>)['data-tabster']).toBeUndefined();
});

it('does not apply focus attrs when card is selectable', () => {
const { result } = renderHook(() => useCard_unstable({ onSelectionChange: jest.fn() }, React.createRef()));
expect(result.current.root.tabIndex).toBeUndefined();
expect((result.current.root as Record<string, unknown>)['data-tabster']).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

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

import type { CardBaseProps, CardBaseState, CardProps, CardState } from './Card.types';
import type { CardBaseProps, CardBaseState, CardOnSelectionChangeEvent, CardProps, CardState } from './Card.types';
import { useCardSelectable } from './useCardSelectable';
import { cardContextDefaultValue } from './CardContext';

Expand All @@ -15,58 +15,30 @@ const focusMap = {
'tab-only': 'unlimited',
} as const;

const interactiveEventProps = [
'onClick',
'onDoubleClick',
'onMouseUp',
'onMouseDown',
'onPointerUp',
'onPointerDown',
'onTouchStart',
'onTouchEnd',
'onDragStart',
'onDragEnd',
] as (keyof React.HTMLAttributes<HTMLElement>)[];

/**
* Create the state for interactive cards.
*
* This internal hook defines if the card is interactive
* and control focus properties based on that.
*
* @param props - props from this instance of Card
* Compute whether a Card is interactive based on the presence of pointer/mouse
* event props and the disabled flag. This intentionally does not depend on
* focus management utilities so it can be used from headless contexts.
*/
const useCardInteractive = ({ focusMode: initialFocusMode, disabled = false, ...props }: CardProps) => {
const interactive = (
[
'onClick',
'onDoubleClick',
'onMouseUp',
'onMouseDown',
'onPointerUp',
'onPointerDown',
'onTouchStart',
'onTouchEnd',
'onDragStart',
'onDragEnd',
] as (keyof React.HTMLAttributes<HTMLElement>)[]
).some(prop => props[prop]);

// default focusMode to tab-only when interactive, and off when not
const focusMode = initialFocusMode ?? (interactive ? 'no-tab' : 'off');

const groupperAttrs = useFocusableGroup({
tabBehavior: focusMap[focusMode],
});

if (disabled) {
return {
interactive: false,
focusAttributes: null,
};
}

if (focusMode === 'off') {
return {
interactive,
focusAttributes: null,
};
const computeInteractive = (props: CardProps): boolean => {
if (props.disabled) {
return false;
}

return {
interactive,
focusAttributes: {
...groupperAttrs,
tabIndex: 0,
},
};
return interactiveEventProps.some(prop => props[prop] !== undefined);
};

/**
Expand All @@ -80,7 +52,50 @@ const useCardInteractive = ({ focusMode: initialFocusMode, disabled = false, ...
*/
export const useCard_unstable = (props: CardProps, ref: React.Ref<HTMLDivElement>): CardState => {
const { appearance = 'filled', orientation = 'vertical', size = 'medium', ...cardProps } = props;
const state = useCardBase_unstable(cardProps, ref);
const { disabled = false, focusMode: focusModeProp } = props;

// Focus-within ref drives the styled focus outline; merged with the user ref
// before being passed down so the base hook does not depend on react-tabster.
const focusWithinRef = useFocusWithin<HTMLDivElement>();
const cardRef = useMergedRefs(focusWithinRef, ref);

// Focus-aware predicate that prevents toggling the selection when the user
// interacts with an inner focusable element.
const { findAllFocusable } = useFocusFinders();
const shouldRestrictTriggerAction = React.useCallback(
(event: CardOnSelectionChangeEvent) => {
if (!focusWithinRef.current) {
return false;
}

const focusableElements = findAllFocusable(focusWithinRef.current);
const target = event.target as HTMLElement;

return focusableElements.some(element => element.contains(target));
},
[findAllFocusable, focusWithinRef],
);

const interactive = computeInteractive(props);
const focusMode = focusModeProp ?? (interactive ? 'no-tab' : 'off');
const groupperAttrs = useFocusableGroup({
tabBehavior: focusMap[focusMode],
});

const state = useCardBase_unstable(
{
shouldRestrictTriggerAction,
...cardProps,
},
cardRef,
);

// Apply focusable-group attributes only when the card is not selectable, not
// disabled and the focus mode is enabled.
const shouldApplyFocusAttributes = !disabled && !state.selectable && focusMode !== 'off';
if (shouldApplyFocusAttributes) {
Object.assign(state.root, groupperAttrs, { tabIndex: 0 });
}

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

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

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

const cardBaseRef = useFocusWithin<HTMLDivElement>();
const { selectable, selected, selectableCardProps, selectFocused, checkboxSlot, floatingActionSlot } =
useCardSelectable(props, { referenceId, referenceLabel }, cardBaseRef);

const cardRef = useMergedRefs(cardBaseRef, ref);
useCardSelectable(props, { referenceId, referenceLabel });

const { interactive, focusAttributes } = useCardInteractive(props);
const interactive = computeInteractive(props);

let cardRootProps = {
...(!selectable ? focusAttributes : null),
...restProps,
...selectableCardProps,
};
Expand Down Expand Up @@ -146,7 +163,7 @@ export const useCardBase_unstable = (props: CardBaseProps, ref: React.Ref<HTMLDi

root: slot.always(
getIntrinsicElementProps('div', {
ref: cardRef,
ref,
role: 'group',
...cardRootProps,
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type * as React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';

import { useCardSelectable } from './useCardSelectable';

const makeA11yProps = () => ({ referenceId: undefined, referenceLabel: undefined });

// useCardSelectable — shouldRestrictTriggerAction is a new optional predicate
// on CardBaseProps; the checkbox target always bypasses it.

describe('useCardSelectable', () => {
it('blocks selection when shouldRestrictTriggerAction returns true', () => {
const onSelectionChange = jest.fn();
const shouldRestrictTriggerAction = jest.fn().mockReturnValue(true);
const { result } = renderHook(() =>
useCardSelectable({ onSelectionChange, shouldRestrictTriggerAction }, makeA11yProps()),
);

const event = { target: document.createElement('span') } as unknown as React.MouseEvent<HTMLDivElement>;
act(() => {
result.current.selectableCardProps!.onClick(event);
});

expect(onSelectionChange).not.toHaveBeenCalled();
expect(shouldRestrictTriggerAction).toHaveBeenCalledWith(event);
});

it('allows selection when shouldRestrictTriggerAction returns false', () => {
const onSelectionChange = jest.fn();
const shouldRestrictTriggerAction = jest.fn().mockReturnValue(false);
const { result } = renderHook(() =>
useCardSelectable({ onSelectionChange, shouldRestrictTriggerAction }, makeA11yProps()),
);

const event = { target: document.createElement('span') } as unknown as React.MouseEvent<HTMLDivElement>;
act(() => {
result.current.selectableCardProps!.onClick(event);
});

expect(onSelectionChange).toHaveBeenCalledTimes(1);
});

it('bypasses shouldRestrictTriggerAction when the checkbox itself is the event target', () => {
const onSelectionChange = jest.fn();
const shouldRestrictTriggerAction = jest.fn().mockReturnValue(true);
const { result } = renderHook(() =>
useCardSelectable({ onSelectionChange, shouldRestrictTriggerAction }, makeA11yProps()),
);

// slot.optional spreads defaultProps directly into the result object.
const checkboxSlot = result.current.checkboxSlot! as unknown as {
ref: React.RefObject<HTMLInputElement>;
onChange: React.ChangeEventHandler<HTMLInputElement>;
};
const checkboxEl = document.createElement('input');
checkboxSlot.ref.current = checkboxEl;

const changeEvent = { target: checkboxEl } as unknown as React.ChangeEvent<HTMLInputElement>;
act(() => {
checkboxSlot.onChange(changeEvent);
});

expect(shouldRestrictTriggerAction).not.toHaveBeenCalled();
expect(onSelectionChange).toHaveBeenCalledTimes(1);
});
});
Loading
Loading