Skip to content

Commit 4615f5e

Browse files
dmytrokirpaclaude
andcommitted
feat(react-card): add useCardBase_unstable hook
Extracts pure headless logic (ARIA, focus management, selectable state) from useCard_unstable into a new useCardBase_unstable hook, following the base-state-hooks RFC. Design props (appearance, orientation, size) remain in useCard_unstable which now composes on top of the base hook. Exports CardBaseProps and CardBaseState types from the react-card package. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cbf6cd2 commit 4615f5e

5 files changed

Lines changed: 194 additions & 13 deletions

File tree

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
2+
import type { ComponentProps, ComponentState, DistributiveOmit, Slot } from '@fluentui/react-utilities';
33

44
/**
55
* Card selected event type
@@ -135,6 +135,12 @@ export type CardProps = ComponentProps<CardSlots> & {
135135
disabled?: boolean;
136136
};
137137

138+
/**
139+
* Card base component props — excludes purely visual design props.
140+
* Use with `useCardBase_unstable` to implement a custom styled Card.
141+
*/
142+
export type CardBaseProps = DistributiveOmit<CardProps, 'appearance' | 'orientation' | 'size'>;
143+
138144
/**
139145
* State used in rendering Card.
140146
*/
@@ -178,3 +184,8 @@ export type CardState = ComponentState<CardSlots> &
178184
disabled: boolean;
179185
}
180186
>;
187+
188+
/**
189+
* State returned by `useCardBase_unstable` — excludes purely visual design state.
190+
*/
191+
export type CardBaseState = DistributiveOmit<CardState, 'appearance' | 'orientation' | 'size'>;

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export { Card } from './Card';
22
export type {
3+
CardBaseProps,
4+
CardBaseState,
35
CardContextValue,
46
CardOnSelectData,
57
CardOnSelectionChangeEvent,
@@ -9,5 +11,5 @@ export type {
911
} from './Card.types';
1012
export { CardProvider, cardContextDefaultValue, useCardContext_unstable } from './CardContext';
1113
export { renderCard_unstable } from './renderCard';
12-
export { useCard_unstable } from './useCard';
14+
export { useCard_unstable, useCardBase_unstable } from './useCard';
1315
export { cardCSSVars, cardClassNames, useCardStyles_unstable } from './useCardStyles.styles';

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

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as React from 'react';
44
import { getIntrinsicElementProps, useMergedRefs, slot } from '@fluentui/react-utilities';
55
import { useFocusableGroup, useFocusWithin } from '@fluentui/react-tabster';
66

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

@@ -70,16 +70,18 @@ const useCardInteractive = ({ focusMode: initialFocusMode, disabled = false, ...
7070
};
7171

7272
/**
73-
* Create the state required to render Card.
73+
* Create the base state required to render Card, without design props.
7474
*
75-
* The returned state can be modified with hooks such as useCardStyles_unstable,
76-
* before being passed to renderCard_unstable.
75+
* Extracts ARIA attributes, keyboard/focus handling, selectable logic, and slot
76+
* structure. Design props (`appearance`, `orientation`, `size`) are intentionally
77+
* excluded — compose this hook with those values in `useCard_unstable` or your
78+
* own styled hook.
7779
*
78-
* @param props - props from this instance of Card
80+
* @param props - props from this instance of Card (design props omitted)
7981
* @param ref - reference to the root element of Card
8082
*/
81-
export const useCard_unstable = (props: CardProps, ref: React.Ref<HTMLDivElement>): CardState => {
82-
const { appearance = 'filled', orientation = 'vertical', size = 'medium', disabled = false, ...restProps } = props;
83+
export const useCardBase_unstable = (props: CardBaseProps, ref: React.Ref<HTMLDivElement>): CardBaseState => {
84+
const { disabled = false, ...restProps } = props;
8385

8486
const [referenceId, setReferenceId] = React.useState(cardContextDefaultValue.selectableA11yProps.referenceId);
8587
const [referenceLabel, setReferenceLabel] = React.useState(cardContextDefaultValue.selectableA11yProps.referenceId);
@@ -107,9 +109,6 @@ export const useCard_unstable = (props: CardProps, ref: React.Ref<HTMLDivElement
107109
}
108110

109111
return {
110-
appearance,
111-
orientation,
112-
size,
113112
interactive,
114113
selectable,
115114
selectFocused,
@@ -141,3 +140,23 @@ export const useCard_unstable = (props: CardProps, ref: React.Ref<HTMLDivElement
141140
checkbox: checkboxSlot,
142141
};
143142
};
143+
144+
/**
145+
* Create the state required to render Card.
146+
*
147+
* The returned state can be modified with hooks such as useCardStyles_unstable,
148+
* before being passed to renderCard_unstable.
149+
*
150+
* @param props - props from this instance of Card
151+
* @param ref - reference to the root element of Card
152+
*/
153+
export const useCard_unstable = (props: CardProps, ref: React.Ref<HTMLDivElement>): CardState => {
154+
const { appearance = 'filled', orientation = 'vertical', size = 'medium' } = props;
155+
156+
return {
157+
...useCardBase_unstable(props, ref),
158+
appearance,
159+
orientation,
160+
size,
161+
};
162+
};
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import * as React from 'react';
2+
import '@testing-library/jest-dom';
3+
import { render, fireEvent } from '@testing-library/react';
4+
import userEvent from '@testing-library/user-event';
5+
6+
import type { CardBaseProps, CardState } from './Card.types';
7+
import { useCardBase_unstable } from './useCard';
8+
import { renderCard_unstable } from './renderCard';
9+
import { useCardContextValue } from './useCardContextValue';
10+
11+
/**
12+
* Minimal styled wrapper that composes `useCardBase_unstable` exactly as a real
13+
* styled hook would: call the base hook, add design defaults, then render.
14+
*/
15+
const TestCard = React.forwardRef<HTMLDivElement, CardBaseProps>((props, ref) => {
16+
const baseState = useCardBase_unstable(props, ref);
17+
18+
// Augment with design defaults to satisfy CardState for the renderer
19+
const state = Object.assign(baseState, {
20+
appearance: 'filled',
21+
orientation: 'vertical',
22+
size: 'medium',
23+
}) as CardState;
24+
25+
const cardContextValue = useCardContextValue(state);
26+
return renderCard_unstable(state, cardContextValue);
27+
});
28+
29+
describe('useCardBase_unstable', () => {
30+
// ── Slot structure ────────────────────────────────────────────────────
31+
32+
it('renders a div with role="group"', () => {
33+
const { getByRole } = render(<TestCard>Content</TestCard>);
34+
expect(getByRole('group')).toBeInTheDocument();
35+
});
36+
37+
it('does not render checkbox or floatingAction by default', () => {
38+
const { container } = render(<TestCard>Content</TestCard>);
39+
expect(container.querySelector('input[type="checkbox"]')).toBeNull();
40+
expect(container.querySelector('.fui-Card__floatingAction')).toBeNull();
41+
});
42+
43+
// ── disabled ──────────────────────────────────────────────────────────
44+
45+
it('sets aria-disabled on the root when disabled', () => {
46+
const { getByRole } = render(<TestCard disabled>Content</TestCard>);
47+
expect(getByRole('group')).toHaveAttribute('aria-disabled', 'true');
48+
});
49+
50+
it('does not fire onClick when disabled', () => {
51+
const onClick = jest.fn();
52+
const { getByRole } = render(
53+
<TestCard disabled onClick={onClick}>
54+
Content
55+
</TestCard>,
56+
);
57+
fireEvent.click(getByRole('group'));
58+
expect(onClick).not.toHaveBeenCalled();
59+
});
60+
61+
it('does not add tabindex when disabled', () => {
62+
const onClick = jest.fn();
63+
const { getByRole } = render(
64+
<TestCard disabled onClick={onClick}>
65+
<button>inner</button>
66+
</TestCard>,
67+
);
68+
expect(getByRole('group').getAttribute('tabindex')).toBeNull();
69+
});
70+
71+
// ── focusMode / interactive ───────────────────────────────────────────
72+
73+
it('does not add tabindex for non-interactive card (no event handlers)', () => {
74+
const { getByTestId } = render(
75+
<TestCard data-testid="card">
76+
<button>inner</button>
77+
</TestCard>,
78+
);
79+
expect(getByTestId('card').getAttribute('tabindex')).toBeNull();
80+
});
81+
82+
it('adds tabindex=0 for interactive card (onClick present)', () => {
83+
const { getByTestId } = render(
84+
<TestCard data-testid="card" onClick={jest.fn()}>
85+
<button>inner</button>
86+
</TestCard>,
87+
);
88+
userEvent.tab();
89+
expect(getByTestId('card').getAttribute('tabindex')).toEqual('0');
90+
});
91+
92+
it('overrides default focusMode when explicitly set to "off"', () => {
93+
const { getByTestId } = render(
94+
<TestCard data-testid="card" onClick={jest.fn()} focusMode="off">
95+
<button>inner</button>
96+
</TestCard>,
97+
);
98+
expect(getByTestId('card').getAttribute('tabindex')).toBeNull();
99+
});
100+
101+
// ── selectable ────────────────────────────────────────────────────────
102+
103+
it('renders a checkbox when onSelectionChange is provided', () => {
104+
const { getByRole } = render(
105+
<TestCard onSelectionChange={jest.fn()}>Content</TestCard>,
106+
);
107+
expect(getByRole('checkbox')).toBeInTheDocument();
108+
});
109+
110+
it('toggles selection on click', () => {
111+
const onSelectionChange = jest.fn();
112+
const { getByRole } = render(
113+
<TestCard defaultSelected={false} onSelectionChange={onSelectionChange}>
114+
Content
115+
</TestCard>,
116+
);
117+
fireEvent.click(getByRole('group'));
118+
expect(onSelectionChange).toHaveBeenCalledWith(expect.anything(), { selected: true });
119+
});
120+
121+
it('does not toggle selection when disabled', () => {
122+
const onSelectionChange = jest.fn();
123+
const { getByRole } = render(
124+
<TestCard disabled defaultSelected={false} onSelectionChange={onSelectionChange}>
125+
Content
126+
</TestCard>,
127+
);
128+
fireEvent.click(getByRole('group'));
129+
expect(onSelectionChange).not.toHaveBeenCalled();
130+
});
131+
132+
it('disables checkbox when card is disabled', () => {
133+
const { getByRole } = render(
134+
<TestCard disabled selected={false} onSelectionChange={jest.fn()}>
135+
Content
136+
</TestCard>,
137+
);
138+
expect(getByRole('checkbox')).toHaveAttribute('disabled');
139+
});
140+
141+
// ── refs ──────────────────────────────────────────────────────────────
142+
143+
it('forwards ref to the root element', () => {
144+
const ref = React.createRef<HTMLDivElement>();
145+
render(<TestCard ref={ref}>Content</TestCard>);
146+
expect(ref.current).toBeInstanceOf(HTMLDivElement);
147+
});
148+
});

packages/react-components/react-card/library/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ export {
55
renderCard_unstable,
66
useCardStyles_unstable,
77
useCard_unstable,
8+
useCardBase_unstable,
89
} from './Card';
9-
export type { CardProps, CardSlots, CardState, CardOnSelectionChangeEvent } from './Card';
10+
export type { CardBaseProps, CardBaseState, CardProps, CardSlots, CardState, CardOnSelectionChangeEvent } from './Card';
1011
export {
1112
CardFooter,
1213
cardFooterClassNames,

0 commit comments

Comments
 (0)