From 499f7df22e6c2b623357086f18a9b69a1e0c9b11 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sun, 19 Apr 2026 14:40:20 +0200 Subject: [PATCH 1/4] 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 00000000000000..635d5f3047f71a --- /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 00af9773ffc5f1..8ce6e799d47fef 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 15008489ee127d..3285b0a6812788 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 83fba148513fb5..8f5aeea4daab62 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 628d23a9c9d86a46e9c4f84c058ae43f62cf6784 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sun, 19 Apr 2026 14:41:12 +0200 Subject: [PATCH 2/4] feat(react-headless-components-preview): add Card component Adds a headless Card with CardHeader, CardFooter, and CardPreview subcomponents that wrap the base hooks from @fluentui/react-card. Selection works (selected, defaultSelected, onSelectionChange, auto-rendered checkbox / floatingAction slots, click and Enter toggling, disabled handling). focusMode is intentionally omitted from CardProps because tabster groupper-style Tab-trap semantics cannot be expressed with the WICG focusgroup polyfill the headless package relies on. Includes Default, Selectable, and Disabled Storybook examples. --- .../react-headless-components-preview.api.md | 98 +++++++++++++++++++ .../library/package.json | 1 + .../library/src/Card.ts | 32 ++++++ .../library/src/components/Card/Card.test.tsx | 18 ++++ .../library/src/components/Card/Card.tsx | 20 ++++ .../library/src/components/Card/Card.types.ts | 38 +++++++ .../components/Card/CardFooter/CardFooter.tsx | 19 ++++ .../Card/CardFooter/CardFooter.types.ts | 20 ++++ .../src/components/Card/CardFooter/index.ts | 4 + .../Card/CardFooter/renderCardFooter.ts | 6 ++ .../Card/CardFooter/useCardFooter.ts | 16 +++ .../components/Card/CardHeader/CardHeader.tsx | 19 ++++ .../Card/CardHeader/CardHeader.types.ts | 20 ++++ .../src/components/Card/CardHeader/index.ts | 4 + .../Card/CardHeader/renderCardHeader.ts | 6 ++ .../Card/CardHeader/useCardHeader.ts | 16 +++ .../Card/CardPreview/CardPreview.tsx | 19 ++++ .../Card/CardPreview/CardPreview.types.ts | 20 ++++ .../src/components/Card/CardPreview/index.ts | 4 + .../Card/CardPreview/renderCardPreview.ts | 6 ++ .../Card/CardPreview/useCardPreview.ts | 16 +++ .../Card/__snapshots__/Card.test.tsx.snap | 11 +++ .../library/src/components/Card/index.ts | 13 +++ .../src/components/Card/renderCard.tsx | 6 ++ .../library/src/components/Card/useCard.ts | 29 ++++++ .../library/src/index.ts | 33 +++++++ .../stories/src/Card/CardDefault.stories.tsx | 76 ++++++++++++++ .../stories/src/Card/CardDescription.md | 10 ++ .../stories/src/Card/CardDisabled.stories.tsx | 45 +++++++++ .../src/Card/CardSelectable.stories.tsx | 91 +++++++++++++++++ .../stories/src/Card/index.stories.tsx | 20 ++++ 31 files changed, 736 insertions(+) create mode 100644 packages/react-components/react-headless-components-preview/library/src/Card.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/Card.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/Card.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/Card.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/CardFooter.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/CardFooter.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/renderCardFooter.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/useCardFooter.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/CardHeader.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/CardHeader.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/renderCardHeader.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/useCardHeader.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/CardPreview.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/CardPreview.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/renderCardPreview.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/useCardPreview.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/__snapshots__/Card.test.tsx.snap create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/renderCard.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Card/useCard.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Card/CardDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Card/CardDescription.md create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Card/CardDisabled.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Card/index.stories.tsx diff --git a/packages/react-components/react-headless-components-preview/library/etc/react-headless-components-preview.api.md b/packages/react-components/react-headless-components-preview/library/etc/react-headless-components-preview.api.md index 41da91dfe8743c..236c0c9a6e5c3e 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/react-headless-components-preview.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/react-headless-components-preview.api.md @@ -40,6 +40,20 @@ import type { BreadcrumbSlots as BreadcrumbSlots_2 } from '@fluentui/react-bread import type { ButtonBaseProps } from '@fluentui/react-button'; import { ButtonBaseState } from '@fluentui/react-button'; import type { ButtonSlots as ButtonSlots_2 } from '@fluentui/react-button'; +import type { CardBaseProps } from '@fluentui/react-card'; +import { CardBaseState } from '@fluentui/react-card'; +import { CardContextValue as CardContextValue_2 } from '@fluentui/react-card'; +import type { CardFooterBaseProps } from '@fluentui/react-card'; +import { CardFooterBaseState } from '@fluentui/react-card'; +import type { CardFooterSlots as CardFooterSlots_2 } from '@fluentui/react-card'; +import type { CardHeaderBaseProps } from '@fluentui/react-card'; +import { CardHeaderBaseState } from '@fluentui/react-card'; +import type { CardHeaderSlots as CardHeaderSlots_2 } from '@fluentui/react-card'; +import type { CardOnSelectionChangeEvent as CardOnSelectionChangeEvent_2 } from '@fluentui/react-card'; +import type { CardPreviewBaseProps } from '@fluentui/react-card'; +import { CardPreviewBaseState } from '@fluentui/react-card'; +import type { CardPreviewSlots as CardPreviewSlots_2 } from '@fluentui/react-card'; +import type { CardSlots as CardSlots_2 } from '@fluentui/react-card'; import type { CheckboxBaseProps } from '@fluentui/react-checkbox'; import { CheckboxBaseState } from '@fluentui/react-checkbox'; import type { CheckboxSlots as CheckboxSlots_2 } from '@fluentui/react-checkbox'; @@ -288,6 +302,60 @@ export type ButtonState = ButtonBaseState & { }; }; +// @public +export const Card: ForwardRefComponent; + +// @public +export type CardContextValue = CardContextValue_2; + +// @public +export const CardFooter: ForwardRefComponent; + +// @public +export type CardFooterProps = CardFooterBaseProps; + +// @public +export type CardFooterSlots = CardFooterSlots_2; + +// @public +export type CardFooterState = CardFooterBaseState; + +// @public +export const CardHeader: ForwardRefComponent; + +// @public +export type CardHeaderProps = CardHeaderBaseProps; + +// @public +export type CardHeaderSlots = CardHeaderSlots_2; + +// @public +export type CardHeaderState = CardHeaderBaseState; + +// @public +export type CardOnSelectionChangeEvent = CardOnSelectionChangeEvent_2; + +// @public +export const CardPreview: ForwardRefComponent; + +// @public +export type CardPreviewProps = CardPreviewBaseProps; + +// @public +export type CardPreviewSlots = CardPreviewSlots_2; + +// @public +export type CardPreviewState = CardPreviewBaseState; + +// @public +export type CardProps = Omit; + +// @public +export type CardSlots = CardSlots_2; + +// @public +export type CardState = CardBaseState; + // @public export const Checkbox: ForwardRefComponent; @@ -494,6 +562,18 @@ export const renderBreadcrumbItem: (state: BreadcrumbItemBaseState) => JSXElemen // @public export const renderButton: (state: ButtonBaseState) => JSXElement; +// @public +export const renderCard: (state: CardBaseState, cardContextValue: CardContextValue_2) => JSXElement; + +// @public +export const renderCardFooter: (state: CardFooterBaseState) => JSXElement; + +// @public +export const renderCardHeader: (state: CardHeaderBaseState) => JSXElement; + +// @public +export const renderCardPreview: (state: CardPreviewBaseState) => JSXElement; + // @public export const renderCheckbox: (state: CheckboxBaseState) => JSXElement; @@ -801,6 +881,24 @@ export const useBreadcrumbItem: (props: BreadcrumbItemProps, ref: React_2.Ref) => ButtonState; +// @public +export const useCard: (props: CardProps, ref: React_2.Ref) => CardState; + +// @public +export const useCardContext: () => CardContextValue_2; + +// @public +export const useCardContextValue: ({ selectableA11yProps }: CardState) => CardContextValue; + +// @public +export const useCardFooter: (props: CardFooterProps, ref: React_2.Ref) => CardFooterState; + +// @public +export const useCardHeader: (props: CardHeaderProps, ref: React_2.Ref) => CardHeaderState; + +// @public +export const useCardPreview: (props: CardPreviewProps, ref: React_2.Ref) => CardPreviewState; + // @public export const useCheckbox: (props: CheckboxProps, ref: React_2.Ref) => CheckboxState; diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index c0c8e930772620..8b3cf3abab1504 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -24,6 +24,7 @@ "@fluentui/react-badge": "^9.5.1", "@fluentui/react-button": "^9.9.0", "@fluentui/react-breadcrumb": "^9.4.0", + "@fluentui/react-card": "^9.6.0", "@fluentui/react-checkbox": "^9.5.17", "@fluentui/react-dialog": "^9.17.3", "@fluentui/react-divider": "^9.7.0", diff --git a/packages/react-components/react-headless-components-preview/library/src/Card.ts b/packages/react-components/react-headless-components-preview/library/src/Card.ts new file mode 100644 index 00000000000000..3705f57cd93403 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/Card.ts @@ -0,0 +1,32 @@ +export { + Card, + renderCard, + useCard, + useCardContext, + useCardContextValue, + CardHeader, + renderCardHeader, + useCardHeader, + CardFooter, + renderCardFooter, + useCardFooter, + CardPreview, + renderCardPreview, + useCardPreview, +} from './components/Card/index'; +export type { + CardSlots, + CardProps, + CardState, + CardContextValue, + CardOnSelectionChangeEvent, + CardHeaderSlots, + CardHeaderProps, + CardHeaderState, + CardFooterSlots, + CardFooterProps, + CardFooterState, + CardPreviewSlots, + CardPreviewProps, + CardPreviewState, +} from './components/Card/index'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.test.tsx new file mode 100644 index 00000000000000..adfc47dccdc7a9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { Card } from './Card'; + +describe('Card', () => { + isConformant({ + Component: Card, + displayName: 'Card', + }); + + // TODO add more tests here, and create visual regression tests in /apps/vr-tests + + it('renders a default state', () => { + const result = render(Default Card); + expect(result.container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.tsx new file mode 100644 index 00000000000000..1357a32afc2a20 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { CardProps } from './Card.types'; +import { useCard, useCardContextValue } from './useCard'; +import { renderCard } from './renderCard'; + +/** + * A card provides scaffolding for hosting actions and content for a single topic. + */ +export const Card: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useCard(props, ref); + const contextValue = useCardContextValue(state); + + return renderCard(state, contextValue); +}); + +Card.displayName = 'Card'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.types.ts new file mode 100644 index 00000000000000..ab6ccb958c496c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/Card.types.ts @@ -0,0 +1,38 @@ +import type { + CardSlots as CardBaseSlots, + CardBaseProps, + CardBaseState, + CardContextValue as CardBaseContextValue, + CardOnSelectionChangeEvent as CardBaseOnSelectionChangeEvent, +} from '@fluentui/react-card'; + +/** + * Card component slots + */ +export type CardSlots = CardBaseSlots; + +/** + * Card component props + * + * Note: `focusMode` is intentionally omitted in the headless package because + * its tabster groupper-style Tab-trap semantics (limited / limited-trap-focus + * / unlimited) cannot be expressed with the WICG `focusgroup` polyfill that + * the headless components rely on. Consumers can implement equivalent + * behavior themselves on top of the rendered DOM. + */ +export type CardProps = Omit; + +/** + * Card component state + */ +export type CardState = CardBaseState; + +/** + * Context value provided by Card to its sub-components. + */ +export type CardContextValue = CardBaseContextValue; + +/** + * Card selected event type. + */ +export type CardOnSelectionChangeEvent = CardBaseOnSelectionChangeEvent; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/CardFooter.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/CardFooter.tsx new file mode 100644 index 00000000000000..3b7b5f42c6497c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/CardFooter.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { CardFooterProps } from './CardFooter.types'; +import { useCardFooter } from './useCardFooter'; +import { renderCardFooter } from './renderCardFooter'; + +/** + * Component to render the footer of a Card. + */ +export const CardFooter: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useCardFooter(props, ref); + + return renderCardFooter(state); +}); + +CardFooter.displayName = 'CardFooter'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/CardFooter.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/CardFooter.types.ts new file mode 100644 index 00000000000000..36e977c54a8c79 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/CardFooter.types.ts @@ -0,0 +1,20 @@ +import type { + CardFooterSlots as CardFooterBaseSlots, + CardFooterBaseProps, + CardFooterBaseState, +} from '@fluentui/react-card'; + +/** + * CardFooter component slots + */ +export type CardFooterSlots = CardFooterBaseSlots; + +/** + * CardFooter component props + */ +export type CardFooterProps = CardFooterBaseProps; + +/** + * CardFooter component state + */ +export type CardFooterState = CardFooterBaseState; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/index.ts new file mode 100644 index 00000000000000..32b48d4285b43a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/index.ts @@ -0,0 +1,4 @@ +export { CardFooter } from './CardFooter'; +export { renderCardFooter } from './renderCardFooter'; +export { useCardFooter } from './useCardFooter'; +export type { CardFooterSlots, CardFooterProps, CardFooterState } from './CardFooter.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/renderCardFooter.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/renderCardFooter.ts new file mode 100644 index 00000000000000..2fd8ed989a71c2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/renderCardFooter.ts @@ -0,0 +1,6 @@ +import { renderCardFooter_unstable } from '@fluentui/react-card'; + +/** + * Renders the final JSX of the CardFooter component, given the state. + */ +export const renderCardFooter = renderCardFooter_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/useCardFooter.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/useCardFooter.ts new file mode 100644 index 00000000000000..2a56f2cc883d94 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardFooter/useCardFooter.ts @@ -0,0 +1,16 @@ +'use client'; + +import type * as React from 'react'; +import { useCardFooterBase_unstable } from '@fluentui/react-card'; + +import type { CardFooterProps, CardFooterState } from './CardFooter.types'; + +/** + * Returns the state for a CardFooter component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderCardFooter`. + */ +export const useCardFooter = (props: CardFooterProps, ref: React.Ref): CardFooterState => { + const state: CardFooterState = useCardFooterBase_unstable(props, ref); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/CardHeader.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/CardHeader.tsx new file mode 100644 index 00000000000000..09c3bf92466543 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/CardHeader.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { CardHeaderProps } from './CardHeader.types'; +import { useCardHeader } from './useCardHeader'; +import { renderCardHeader } from './renderCardHeader'; + +/** + * Component to render an image, text and an action in a Card component. + */ +export const CardHeader: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useCardHeader(props, ref); + + return renderCardHeader(state); +}); + +CardHeader.displayName = 'CardHeader'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/CardHeader.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/CardHeader.types.ts new file mode 100644 index 00000000000000..44a8721e8a0bec --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/CardHeader.types.ts @@ -0,0 +1,20 @@ +import type { + CardHeaderSlots as CardHeaderBaseSlots, + CardHeaderBaseProps, + CardHeaderBaseState, +} from '@fluentui/react-card'; + +/** + * CardHeader component slots + */ +export type CardHeaderSlots = CardHeaderBaseSlots; + +/** + * CardHeader component props + */ +export type CardHeaderProps = CardHeaderBaseProps; + +/** + * CardHeader component state + */ +export type CardHeaderState = CardHeaderBaseState; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/index.ts new file mode 100644 index 00000000000000..70f5d517d01d95 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/index.ts @@ -0,0 +1,4 @@ +export { CardHeader } from './CardHeader'; +export { renderCardHeader } from './renderCardHeader'; +export { useCardHeader } from './useCardHeader'; +export type { CardHeaderSlots, CardHeaderProps, CardHeaderState } from './CardHeader.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/renderCardHeader.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/renderCardHeader.ts new file mode 100644 index 00000000000000..9cbb7d02d3849d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/renderCardHeader.ts @@ -0,0 +1,6 @@ +import { renderCardHeader_unstable } from '@fluentui/react-card'; + +/** + * Renders the final JSX of the CardHeader component, given the state. + */ +export const renderCardHeader = renderCardHeader_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/useCardHeader.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/useCardHeader.ts new file mode 100644 index 00000000000000..2d2e63db5f3c28 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardHeader/useCardHeader.ts @@ -0,0 +1,16 @@ +'use client'; + +import type * as React from 'react'; +import { useCardHeaderBase_unstable } from '@fluentui/react-card'; + +import type { CardHeaderProps, CardHeaderState } from './CardHeader.types'; + +/** + * Returns the state for a CardHeader component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderCardHeader`. + */ +export const useCardHeader = (props: CardHeaderProps, ref: React.Ref): CardHeaderState => { + const state: CardHeaderState = useCardHeaderBase_unstable(props, ref); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/CardPreview.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/CardPreview.tsx new file mode 100644 index 00000000000000..09271921ee4a90 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/CardPreview.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { CardPreviewProps } from './CardPreview.types'; +import { useCardPreview } from './useCardPreview'; +import { renderCardPreview } from './renderCardPreview'; + +/** + * Component to render a media preview within a Card component. + */ +export const CardPreview: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useCardPreview(props, ref); + + return renderCardPreview(state); +}); + +CardPreview.displayName = 'CardPreview'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/CardPreview.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/CardPreview.types.ts new file mode 100644 index 00000000000000..4cb34a2ba574b4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/CardPreview.types.ts @@ -0,0 +1,20 @@ +import type { + CardPreviewSlots as CardPreviewBaseSlots, + CardPreviewBaseProps, + CardPreviewBaseState, +} from '@fluentui/react-card'; + +/** + * CardPreview component slots + */ +export type CardPreviewSlots = CardPreviewBaseSlots; + +/** + * CardPreview component props + */ +export type CardPreviewProps = CardPreviewBaseProps; + +/** + * CardPreview component state + */ +export type CardPreviewState = CardPreviewBaseState; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/index.ts new file mode 100644 index 00000000000000..9044fd7634f299 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/index.ts @@ -0,0 +1,4 @@ +export { CardPreview } from './CardPreview'; +export { renderCardPreview } from './renderCardPreview'; +export { useCardPreview } from './useCardPreview'; +export type { CardPreviewSlots, CardPreviewProps, CardPreviewState } from './CardPreview.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/renderCardPreview.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/renderCardPreview.ts new file mode 100644 index 00000000000000..5918a71c880951 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/renderCardPreview.ts @@ -0,0 +1,6 @@ +import { renderCardPreview_unstable } from '@fluentui/react-card'; + +/** + * Renders the final JSX of the CardPreview component, given the state. + */ +export const renderCardPreview = renderCardPreview_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/useCardPreview.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/useCardPreview.ts new file mode 100644 index 00000000000000..1901597268d2e2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/CardPreview/useCardPreview.ts @@ -0,0 +1,16 @@ +'use client'; + +import type * as React from 'react'; +import { useCardPreviewBase_unstable } from '@fluentui/react-card'; + +import type { CardPreviewProps, CardPreviewState } from './CardPreview.types'; + +/** + * Returns the state for a CardPreview component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderCardPreview`. + */ +export const useCardPreview = (props: CardPreviewProps, ref: React.Ref): CardPreviewState => { + const state: CardPreviewState = useCardPreviewBase_unstable(props, ref); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/__snapshots__/Card.test.tsx.snap b/packages/react-components/react-headless-components-preview/library/src/components/Card/__snapshots__/Card.test.tsx.snap new file mode 100644 index 00000000000000..906e1e8ee88760 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/__snapshots__/Card.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Card renders a default state 1`] = ` +
+
+ Default Card +
+
+`; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/index.ts new file mode 100644 index 00000000000000..3af14857aeb0c5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/index.ts @@ -0,0 +1,13 @@ +export { Card } from './Card'; +export { renderCard } from './renderCard'; +export { useCard, useCardContext, useCardContextValue } from './useCard'; +export type { CardSlots, CardProps, CardState, CardContextValue, CardOnSelectionChangeEvent } from './Card.types'; + +export { CardHeader, renderCardHeader, useCardHeader } from './CardHeader'; +export type { CardHeaderSlots, CardHeaderProps, CardHeaderState } from './CardHeader'; + +export { CardFooter, renderCardFooter, useCardFooter } from './CardFooter'; +export type { CardFooterSlots, CardFooterProps, CardFooterState } from './CardFooter'; + +export { CardPreview, renderCardPreview, useCardPreview } from './CardPreview'; +export type { CardPreviewSlots, CardPreviewProps, CardPreviewState } from './CardPreview'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/renderCard.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Card/renderCard.tsx new file mode 100644 index 00000000000000..bfa9be7b3dab7c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/renderCard.tsx @@ -0,0 +1,6 @@ +import { renderCard_unstable } from '@fluentui/react-card'; + +/** + * Renders the final JSX of the Card component, given the state and context value. + */ +export const renderCard = renderCard_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Card/useCard.ts b/packages/react-components/react-headless-components-preview/library/src/components/Card/useCard.ts new file mode 100644 index 00000000000000..cf124edbdddad9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Card/useCard.ts @@ -0,0 +1,29 @@ +'use client'; + +import type * as React from 'react'; +import { useCardBase_unstable, useCardContext_unstable } from '@fluentui/react-card'; + +import type { CardContextValue, CardProps, CardState } from './Card.types'; + +/** + * Returns the state for a Card component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderCard`. + */ +export const useCard = (props: CardProps, ref: React.Ref): CardState => { + const state: CardState = useCardBase_unstable(props, ref); + + return state; +}; + +/** + * Returns the context value provided by the nearest Card, enabling child components to + * read card-level state such as the selectable accessibility properties. + */ +export const useCardContext = useCardContext_unstable; + +/** + * Maps Card state to the context value passed down to child components. + */ +export const useCardContextValue = ({ selectableA11yProps }: CardState): CardContextValue => { + return { selectableA11yProps }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/index.ts b/packages/react-components/react-headless-components-preview/library/src/index.ts index 288cd943660f3b..719db283bb3ed6 100644 --- a/packages/react-components/react-headless-components-preview/library/src/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/index.ts @@ -71,6 +71,39 @@ export type { export { Button, renderButton, useButton } from './Button'; export type { ButtonSlots, ButtonProps, ButtonState } from './Button'; +export { + Card, + renderCard, + useCard, + useCardContext, + useCardContextValue, + CardHeader, + renderCardHeader, + useCardHeader, + CardFooter, + renderCardFooter, + useCardFooter, + CardPreview, + renderCardPreview, + useCardPreview, +} from './Card'; +export type { + CardSlots, + CardProps, + CardState, + CardContextValue, + CardOnSelectionChangeEvent, + CardHeaderSlots, + CardHeaderProps, + CardHeaderState, + CardFooterSlots, + CardFooterProps, + CardFooterState, + CardPreviewSlots, + CardPreviewProps, + CardPreviewState, +} from './Card'; + export { Checkbox, renderCheckbox, useCheckbox } from './Checkbox'; export type { CheckboxSlots, CheckboxProps, CheckboxState } from './Checkbox'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/CardDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Card/CardDefault.stories.tsx new file mode 100644 index 00000000000000..38a71546c97c0e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/CardDefault.stories.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; +import { Card, CardHeader, CardPreview, CardFooter } from '@fluentui/react-headless-components-preview'; +import { MoreHorizontalRegular, ShareRegular, ArrowReplyRegular } from '@fluentui/react-icons'; + +const classes = { + card: + 'flex flex-col gap-3 w-80 p-3 bg-white rounded-lg border border-gray-200 shadow-sm ' + + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', + preview: 'flex items-center justify-center bg-gray-100 rounded-md overflow-hidden -mx-3 -mt-3', + previewImage: 'block w-full h-40 object-cover', + header: 'flex items-center gap-3', + headerImage: 'flex h-10 w-10 rounded-md overflow-hidden bg-gray-100', + headerImg: 'h-full w-full object-cover', + headerTitle: 'text-sm font-semibold text-gray-900 leading-tight', + headerDescription: 'text-xs text-gray-500 leading-tight', + headerAction: 'ml-auto flex items-center', + iconButton: + 'inline-flex items-center justify-center h-8 w-8 rounded-md text-gray-600 ' + + 'hover:bg-gray-100 active:bg-gray-200 ' + + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', + body: 'text-sm text-gray-700 leading-snug', + footer: 'flex items-center gap-2 pt-1', + footerButton: + 'inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm text-gray-700 border border-gray-200 ' + + 'hover:bg-gray-100 active:bg-gray-200 ' + + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', +}; + +export const Default = (): React.ReactNode => ( + + + Preview + + + + + + } + header={
App Name
} + description={
Developer
} + action={ +
+ +
+ } + /> + +
+ Donut chocolate bar oat cake. Dragée tiramisu lollipop bear claw. Marshmallow pastry jujubes toffee sugar plum. +
+ + + + + +
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/CardDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Card/CardDescription.md new file mode 100644 index 00000000000000..64107cbd2ecd85 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/CardDescription.md @@ -0,0 +1,10 @@ +A card is a container that holds information and actions related to a single concept or object, like a document or a contact. + +Cards can give information prominence and create predictable patterns. While they're very flexible, it's important to use them consistently for particular use cases across experiences. + +## Best practices + +### Accessibility + +- By default, each card is of role="group". +- For larger Cards that have a single title, use a heading tag to wrap the title text. The specific heading level should be determined by the specific context in which it is used. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/CardDisabled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Card/CardDisabled.stories.tsx new file mode 100644 index 00000000000000..9528964f7cc860 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/CardDisabled.stories.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { Card, CardHeader, CardPreview } from '@fluentui/react-headless-components-preview'; + +const classes = { + card: + 'relative flex flex-col gap-3 w-80 p-3 bg-white rounded-lg border border-gray-200 shadow-sm ' + + 'aria-disabled:opacity-50 aria-disabled:cursor-not-allowed', + checkbox: 'absolute top-3 left-3 h-4 w-4 accent-blue-600 disabled:cursor-not-allowed', + preview: 'flex items-center justify-center bg-gray-100 rounded-md overflow-hidden -mx-3 -mt-3', + previewImage: 'block w-full h-40 object-cover', + header: 'flex items-center gap-3 pl-6', + headerTitle: 'text-sm font-semibold text-gray-900 leading-tight', + headerDescription: 'text-xs text-gray-500 leading-tight', + body: 'text-sm text-gray-700 leading-snug', +}; + +export const Disabled = (): React.ReactNode => ( + { + /* no-op */ + }} + checkbox={{ className: classes.checkbox, 'aria-label': 'Select card' }} + > + + Preview + + + Disabled card} + description={
Selection is locked
} + /> + +
+ A disabled card sets `aria-disabled="true"` on the root and short-circuits selection toggling. +
+
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx new file mode 100644 index 00000000000000..0706490700bd9e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; +import { Card, CardHeader, CardPreview, CardOnSelectionChangeEvent } from '@fluentui/react-headless-components-preview'; +import { MoreHorizontalRegular } from '@fluentui/react-icons'; + +const classes = { + card: + 'relative flex flex-col gap-3 w-80 p-3 bg-white rounded-lg border border-gray-200 shadow-sm cursor-pointer ' + + 'hover:bg-gray-50 transition-colors ' + + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 ' + + 'data-[state=checked]:border-blue-500 data-[state=checked]:ring-2 data-[state=checked]:ring-blue-500 ' + + 'aria-disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:hover:bg-white', + checkbox: + 'absolute top-3 left-3 h-4 w-4 cursor-pointer accent-blue-600 ' + + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', + preview: 'flex items-center justify-center bg-gray-100 rounded-md overflow-hidden -mx-3 -mt-3', + previewImage: 'block w-full h-40 object-cover', + header: 'flex items-center gap-3 pl-6', + headerImage: 'flex h-10 w-10 rounded-md overflow-hidden bg-gray-100', + headerImg: 'h-full w-full object-cover', + headerTitle: 'text-sm font-semibold text-gray-900 leading-tight', + headerDescription: 'text-xs text-gray-500 leading-tight', + headerAction: 'ml-auto flex items-center', + iconButton: + 'inline-flex items-center justify-center h-8 w-8 rounded-md text-gray-600 ' + + 'hover:bg-gray-100 active:bg-gray-200 ' + + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2', + body: 'text-sm text-gray-700 leading-snug', + status: 'text-xs text-gray-500', +}; + +const CardContent = ({ title }: { title: string }): React.ReactNode => ( + <> + + Preview + + + + + + } + header={
{title}
} + description={
Developer
} + action={ +
+ +
+ } + /> + +
+ Donut chocolate bar oat cake. Dragée tiramisu lollipop bear claw. Marshmallow pastry jujubes toffee sugar plum. +
+ +); + +export const Selectable = (): React.ReactNode => { + const [selected, setSelected] = React.useState(false); + + const onSelectionChange = (_event: CardOnSelectionChangeEvent, data: { selected: boolean }) => { + setSelected(data.selected); + }; + + return ( +
+ + + + +

Selected: {String(selected)}

+
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Card/index.stories.tsx new file mode 100644 index 00000000000000..781b5ef7db4692 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/index.stories.tsx @@ -0,0 +1,20 @@ +import { Card, CardHeader, CardPreview, CardFooter } from '@fluentui/react-headless-components-preview'; + +import descriptionMd from './CardDescription.md'; + +export { Default } from './CardDefault.stories'; +export { Selectable } from './CardSelectable.stories'; +export { Disabled } from './CardDisabled.stories'; + +export default { + title: 'Headless Components/Card', + component: Card, + subcomponents: { CardHeader, CardPreview, CardFooter }, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; From 9e63150b1c505361feea1a48836bea582fc1eca6 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sun, 19 Apr 2026 15:16:04 +0200 Subject: [PATCH 3/4] fix lint --- .../stories/src/Card/CardSelectable.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx index 0706490700bd9e..183cccbdc6d6d0 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { Card, CardHeader, CardPreview, CardOnSelectionChangeEvent } from '@fluentui/react-headless-components-preview'; +import type { CardOnSelectionChangeEvent } from '@fluentui/react-headless-components-preview'; +import { Card, CardHeader, CardPreview } from '@fluentui/react-headless-components-preview'; import { MoreHorizontalRegular } from '@fluentui/react-icons'; const classes = { From 5a84fe25c41b47b1b858cdd5a86f16b1dab38ddb Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Wed, 22 Apr 2026 15:40:01 +0200 Subject: [PATCH 4/4] fix reacy-17 integration test type issue --- .../stories/src/Card/CardSelectable.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx index 183cccbdc6d6d0..6951d59466726b 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Card/CardSelectable.stories.tsx @@ -29,7 +29,7 @@ const classes = { status: 'text-xs text-gray-500', }; -const CardContent = ({ title }: { title: string }): React.ReactNode => ( +const CardContent = ({ title }: { title: string }): React.ReactElement => ( <>