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 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Charts-DonutChart.Dynamic.default.chromium.png 5581 Changed
vr-tests-react-components/Menu 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu.Nested Submenus Small Viewport Flipped.nested menu.chromium.png 122 Changed
vr-tests-react-components/Menu Converged - submenuIndicator slotted content 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default - RTL.submenus open.chromium.png 404 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.chromium.png 878 Changed
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 623 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 51 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 60 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 61 Changed
vr-tests-react-components/TagPicker 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - High Contrast.disabled input hover.chromium.png 1319 Changed

There were 1 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
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
Expand Up @@ -3,27 +3,25 @@
import * as React from 'react';
import { mergeCallbacks, slot, useControllableState } from '@fluentui/react-utilities';
import { Enter } from '@fluentui/keyboard-keys';
import { useFocusFinders } from '@fluentui/react-tabster';

import type { CardContextValue, CardOnSelectionChangeEvent, CardProps, CardSlots, CardState } from './Card.types';
import type { CardBaseProps, CardContextValue, CardOnSelectionChangeEvent, CardSlots, CardState } from './Card.types';

type SelectableA11yProps = Pick<CardContextValue['selectableA11yProps'], 'referenceId' | 'referenceLabel'>;

/**
* Create the state related to selectable cards.
*
* This internal hook controls all the logic for selectable cards and is
* intended to be used alongside with useCard_unstable.
* intended to be used alongside with useCardBase_unstable / useCard_unstable.
*
* @internal
* @param props - props from this instance of Card
* @param a11yProps - accessibility props shared between elements of the card
* @param cardRef - reference to the root element of Card
* @param options - optional behavior overrides such as a focus-aware restriction predicate
*/
export const useCardSelectable = (
props: CardProps,
props: CardBaseProps,
{ referenceLabel, referenceId }: SelectableA11yProps,
cardRef: React.RefObject<HTMLDivElement | null>,
): {
selected: boolean;
selectable: boolean;
Expand All @@ -35,9 +33,16 @@ export const useCardSelectable = (
checkboxSlot: CardState['checkbox'];
floatingActionSlot: CardState['floatingAction'];
} => {
const { checkbox = {}, onSelectionChange, floatingAction, onClick, onKeyDown, disabled } = props;
const {
checkbox = {},
onSelectionChange,
floatingAction,
onClick,
onKeyDown,
disabled,
shouldRestrictTriggerAction,
} = props;

const { findAllFocusable } = useFocusFinders();
const checkboxRef = React.useRef<HTMLInputElement>(null);

const [selected, setSelected] = useControllableState({
Expand All @@ -51,25 +56,15 @@ export const useCardSelectable = (

const [selectFocused, setSelectFocused] = React.useState(false);

const shouldRestrictTriggerAction = React.useCallback(
const onChangeHandler = React.useCallback(
(event: CardOnSelectionChangeEvent) => {
if (!cardRef.current) {
return false;
if (disabled) {
return;
}

const focusableElements = findAllFocusable(cardRef.current);
const target = event.target as HTMLElement;
const isElementInFocusableGroup = focusableElements.some(element => element.contains(target));
const isCheckboxSlot = checkboxRef?.current === target;
const isCheckboxOrFloatingActionTarget = checkboxRef.current === (event.target as HTMLElement);

return isElementInFocusableGroup && !isCheckboxSlot;
},
[cardRef, findAllFocusable],
);

const onChangeHandler = React.useCallback(
(event: CardOnSelectionChangeEvent) => {
if (disabled || shouldRestrictTriggerAction(event)) {
if (!isCheckboxOrFloatingActionTarget && shouldRestrictTriggerAction?.(event)) {
return;
}

Expand Down
Loading
Loading