From 499f7df22e6c2b623357086f18a9b69a1e0c9b11 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sun, 19 Apr 2026 14:40:20 +0200 Subject: [PATCH 1/2] refactor(react-card): make base hooks tabster-free Move @fluentui/react-tabster usage out of useCardBase_unstable and useCardSelectable into useCard_unstable. The base hooks no longer manage focusable-group attributes or the focus-aware selection restriction predicate. useCardSelectable now reads an optional shouldRestrictTriggerAction predicate from CardBaseProps; useCard_unstable provides one based on useFocusFinders + useFocusWithin to preserve existing behavior. This lets headless component packages consume the base hooks without pulling in @fluentui/react-tabster. --- ...-477da387-3e1b-4b6b-86bc-657ea08c4310.json | 7 + .../react-card/library/etc/react-card.api.md | 4 +- .../library/src/components/Card/Card.types.ts | 7 +- .../library/src/components/Card/useCard.ts | 135 ++++++++++-------- .../src/components/Card/useCardSelectable.ts | 41 +++--- 5 files changed, 110 insertions(+), 84 deletions(-) create mode 100644 change/@fluentui-react-card-477da387-3e1b-4b6b-86bc-657ea08c4310.json diff --git a/change/@fluentui-react-card-477da387-3e1b-4b6b-86bc-657ea08c4310.json b/change/@fluentui-react-card-477da387-3e1b-4b6b-86bc-657ea08c4310.json new file mode 100644 index 0000000000000..635d5f3047f71 --- /dev/null +++ b/change/@fluentui-react-card-477da387-3e1b-4b6b-86bc-657ea08c4310.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Make Card base hooks tabster-free; expose shouldRestrictTriggerAction on CardBaseProps", + "packageName": "@fluentui/react-card", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-card/library/etc/react-card.api.md b/packages/react-components/react-card/library/etc/react-card.api.md index 00af9773ffc5f..8ce6e799d47fe 100644 --- a/packages/react-components/react-card/library/etc/react-card.api.md +++ b/packages/react-components/react-card/library/etc/react-card.api.md @@ -16,7 +16,9 @@ import type { SlotClassNames } from '@fluentui/react-utilities'; export const Card: ForwardRefComponent; // @public (undocumented) -export type CardBaseProps = Omit; +export type CardBaseProps = Omit & { + shouldRestrictTriggerAction?: (event: CardOnSelectionChangeEvent) => boolean; +}; // @public (undocumented) export type CardBaseState = Omit; diff --git a/packages/react-components/react-card/library/src/components/Card/Card.types.ts b/packages/react-components/react-card/library/src/components/Card/Card.types.ts index 15008489ee127..3285b0a681278 100644 --- a/packages/react-components/react-card/library/src/components/Card/Card.types.ts +++ b/packages/react-components/react-card/library/src/components/Card/Card.types.ts @@ -135,7 +135,12 @@ export type CardProps = ComponentProps & { disabled?: boolean; }; -export type CardBaseProps = Omit; +export type CardBaseProps = Omit & { + /** + * Predicate function to determine whether the card's selection action should be restricted. + */ + shouldRestrictTriggerAction?: (event: CardOnSelectionChangeEvent) => boolean; +}; /** * State used in rendering Card. diff --git a/packages/react-components/react-card/library/src/components/Card/useCard.ts b/packages/react-components/react-card/library/src/components/Card/useCard.ts index 83fba148513fb..8f5aeea4daab6 100644 --- a/packages/react-components/react-card/library/src/components/Card/useCard.ts +++ b/packages/react-components/react-card/library/src/components/Card/useCard.ts @@ -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'; @@ -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)[]; + /** - * 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)[] - ).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); }; /** @@ -80,7 +52,50 @@ const useCardInteractive = ({ focusMode: initialFocusMode, disabled = false, ... */ export const useCard_unstable = (props: CardProps, ref: React.Ref): 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(); + 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, @@ -92,10 +107,16 @@ export const useCard_unstable = (props: CardProps, ref: React.Ref): CardBaseState => { const { disabled = false, ...restProps } = props; @@ -103,16 +124,12 @@ export const useCardBase_unstable = (props: CardBaseProps, ref: React.Ref(); 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, }; @@ -146,7 +163,7 @@ export const useCardBase_unstable = (props: CardBaseProps, ref: React.Ref; @@ -13,17 +12,16 @@ type SelectableA11yProps = Pick, ): { selected: boolean; selectable: boolean; @@ -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(null); const [selected, setSelected] = useControllableState({ @@ -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; } From b1b721dc654d987a77433a893ac2a3c9f8fa7a9a Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 21 Apr 2026 12:00:36 +0200 Subject: [PATCH 2/2] add tests for useCard and useCardSelectable hooks --- .../src/components/Card/useCard.test.ts | 84 +++++++++++++++++++ .../components/Card/useCardSelectable.test.ts | 66 +++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 packages/react-components/react-card/library/src/components/Card/useCard.test.ts create mode 100644 packages/react-components/react-card/library/src/components/Card/useCardSelectable.test.ts diff --git a/packages/react-components/react-card/library/src/components/Card/useCard.test.ts b/packages/react-components/react-card/library/src/components/Card/useCard.test.ts new file mode 100644 index 0000000000000..eb19fd147fa7d --- /dev/null +++ b/packages/react-components/react-card/library/src/components/Card/useCard.test.ts @@ -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(); +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)['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)['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)['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)['data-tabster']).toBeUndefined(); + }); +}); diff --git a/packages/react-components/react-card/library/src/components/Card/useCardSelectable.test.ts b/packages/react-components/react-card/library/src/components/Card/useCardSelectable.test.ts new file mode 100644 index 0000000000000..de602e98ddef7 --- /dev/null +++ b/packages/react-components/react-card/library/src/components/Card/useCardSelectable.test.ts @@ -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; + 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; + 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; + onChange: React.ChangeEventHandler; + }; + const checkboxEl = document.createElement('input'); + checkboxSlot.ref.current = checkboxEl; + + const changeEvent = { target: checkboxEl } as unknown as React.ChangeEvent; + act(() => { + checkboxSlot.onChange(changeEvent); + }); + + expect(shouldRestrictTriggerAction).not.toHaveBeenCalled(); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + }); +});