diff --git a/apps/vr-tests-react-components/src/stories/Card.stories.tsx b/apps/vr-tests-react-components/src/stories/Card.stories.tsx index 1dcc0fc4a5585..8e38d5b98b0a4 100644 --- a/apps/vr-tests-react-components/src/stories/Card.stories.tsx +++ b/apps/vr-tests-react-components/src/stories/Card.stories.tsx @@ -133,9 +133,9 @@ storiesOf('Card Converged', module) steps={new Screener.Steps() .snapshot('normal', { cropTo: '.testWrapper' }) .hover('[role="group"]') - .snapshot('focused', { cropTo: '.testWrapper' }) + .snapshot('hover', { cropTo: '.testWrapper' }) .mouseDown('[role="group"]') - .snapshot('clicked', { cropTo: '.testWrapper' }) + .snapshot('click', { cropTo: '.testWrapper' }) .end()} >
@@ -151,7 +151,6 @@ storiesOf('Card Converged', module) ), { - includeRtl: true, includeHighContrast: true, includeDarkMode: true, }, @@ -164,7 +163,6 @@ storiesOf('Card Converged', module) ), { - includeRtl: true, includeHighContrast: true, includeDarkMode: true, }, @@ -177,7 +175,6 @@ storiesOf('Card Converged', module) ), { - includeRtl: true, includeHighContrast: true, includeDarkMode: true, }, @@ -190,8 +187,83 @@ storiesOf('Card Converged', module) ), { - includeRtl: true, includeHighContrast: true, includeDarkMode: true, }, ); + +storiesOf('Card Converged', module) + .addDecorator(story => ( + +
+ {story()} +
+
+ )) + .addStory( + 'appearance selectable - Filled', + () => ( + + + + ), + { + includeRtl: true, + includeHighContrast: true, + includeDarkMode: true, + }, + ) + .addStory( + 'appearance selectable - Filled Alternative', + () => ( + + + + ), + { + includeRtl: true, + includeHighContrast: true, + includeDarkMode: true, + }, + ) + .addStory( + 'appearance selectable - Outline', + () => ( + + + + ), + { + includeRtl: true, + includeHighContrast: true, + includeDarkMode: true, + }, + ) + .addStory( + 'appearance selectable - Subtle', + () => ( + + + + ), + { + includeRtl: true, + includeHighContrast: true, + includeDarkMode: true, + }, + ) + .addStory('appearance focusable + selectable', () => ( + + + + )); diff --git a/change/@fluentui-react-card-a5510e2b-cbb7-44ca-aec6-20635cf9f85e.json b/change/@fluentui-react-card-a5510e2b-cbb7-44ca-aec6-20635cf9f85e.json new file mode 100644 index 0000000000000..543a077d9ab56 --- /dev/null +++ b/change/@fluentui-react-card-a5510e2b-cbb7-44ca-aec6-20635cf9f85e.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: add card selection functionality", + "packageName": "@fluentui/react-card", + "email": "39736248+andrefcdias@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-card/e2e/Card.e2e.tsx b/packages/react-components/react-card/e2e/Card.e2e.tsx index 66c7bfa324e0d..339c7df430ae0 100644 --- a/packages/react-components/react-card/e2e/Card.e2e.tsx +++ b/packages/react-components/react-card/e2e/Card.e2e.tsx @@ -4,6 +4,7 @@ import type {} from '@cypress/react'; import { FluentProvider } from '@fluentui/react-provider'; import { webLightTheme } from '@fluentui/react-theme'; import { Button } from '@fluentui/react-button'; +import { checkboxClassNames } from '@fluentui/react-checkbox'; import { Card, CardFooter, CardHeader } from '@fluentui/react-card'; import type { CardProps } from '@fluentui/react-card'; @@ -12,7 +13,7 @@ const mountFluent = (element: JSX.Element) => { }; const CardSample = (props: CardProps) => { - const ASSET_URL = 'https://raw.githubusercontent.com/microsoft/fluentui/master/packages/react-card'; + const ASSET_URL = 'https://raw.githubusercontent.com/microsoft/fluentui/master/packages/react-components/react-card'; const powerpointLogoURL = ASSET_URL + '/assets/powerpoint_logo.svg'; @@ -32,10 +33,10 @@ const CardSample = (props: CardProps) => { plum.
- - @@ -240,4 +241,116 @@ describe('Card', () => { }); }); }); + + describe('selectable', () => { + it('should not be selectable by default', () => { + mountFluent(); + + cy.get(`.${checkboxClassNames.input}`).should('not.exist'); + }); + + it('should show checkbox when selectable', () => { + mountFluent(); + + cy.get(`.${checkboxClassNames.input}`).should('exist').should('not.be.checked'); + }); + + it('should select with a mouse click', () => { + mountFluent(); + + cy.get(`.${checkboxClassNames.input}`).realClick(); + + cy.get(`.${checkboxClassNames.input}`).should('be.checked'); + }); + + it('should select with the Enter key', () => { + mountFluent(); + + cy.get(`.${checkboxClassNames.input}`).focus().realPress('Enter'); + + cy.get(`.${checkboxClassNames.input}`).should('be.checked'); + }); + + it('should select with the Space key', () => { + mountFluent(); + + cy.get(`.${checkboxClassNames.input}`).focus().realPress('Space'); + + cy.get(`.${checkboxClassNames.input}`).should('be.checked'); + }); + + it('should select with a mouse click anywhere on the card', () => { + mountFluent(); + + cy.get(`#card`).realClick(); + + cy.get(`.${checkboxClassNames.input}`).should('be.checked'); + }); + + it('should select with the Enter key anywhere on the card', () => { + mountFluent(); + + cy.get(`#card`).focus().realPress('Enter'); + + cy.get(`.${checkboxClassNames.input}`).should('be.checked'); + }); + + it('should select with the Space key anywhere on the card', () => { + mountFluent(); + + cy.get(`#card`).focus().realPress('Space'); + + cy.get(`.${checkboxClassNames.input}`).should('be.checked'); + }); + }); + + describe('focusable + selectable', () => { + it('should not select on click', () => { + mountFluent(); + + cy.get(`#card`).realClick(); + + cy.get(`.${checkboxClassNames.input}`).should('not.be.checked'); + }); + + it('should not select on Enter key', () => { + mountFluent(); + + cy.get(`#card`).focus().realPress('Enter'); + + cy.get(`.${checkboxClassNames.input}`).should('not.be.checked'); + }); + + it('should not select on Space key', () => { + mountFluent(); + + cy.get(`#card`).focus().realPress('Space'); + + cy.get(`.${checkboxClassNames.input}`).should('not.be.checked'); + }); + + it('should select on checkbox click', () => { + mountFluent(); + + cy.get(`.${checkboxClassNames.input}`).realClick(); + + cy.get(`.${checkboxClassNames.input}`).should('be.checked'); + }); + + it('should not select on checkbox Enter key press', () => { + mountFluent(); + + cy.get(`.${checkboxClassNames.input}`).focus().realPress('Enter'); + + cy.get(`.${checkboxClassNames.input}`).should('not.be.checked'); + }); + + it('should select on checkbox Space key press', () => { + mountFluent(); + + cy.get(`.${checkboxClassNames.input}`).focus().realPress('Space'); + + cy.get(`.${checkboxClassNames.input}`).should('be.checked'); + }); + }); }); diff --git a/packages/react-components/react-card/etc/react-card.api.md b/packages/react-components/react-card/etc/react-card.api.md index e8aac48fbf713..7ff3152877c47 100644 --- a/packages/react-components/react-card/etc/react-card.api.md +++ b/packages/react-components/react-card/etc/react-card.api.md @@ -4,6 +4,9 @@ ```ts +/// + +import type { Checkbox } from '@fluentui/react-checkbox'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; @@ -87,15 +90,20 @@ export type CardProps = ComponentProps & { focusMode?: 'off' | 'no-tab' | 'tab-exit' | 'tab-only'; orientation?: 'horizontal' | 'vertical'; size?: 'small' | 'medium' | 'large'; + selectable?: boolean; + selected?: boolean; + defaultSelected?: boolean; + onCardSelect?: (event: React_2.MouseEvent | React_2.KeyboardEvent | React_2.ChangeEvent, data: CardOnSelectData) => void; }; // @public export type CardSlots = { root: Slot<'div'>; + select?: Slot; }; // @public -export type CardState = ComponentState & Required>; +export type CardState = ComponentState & Required>; // @public export const renderCard_unstable: (state: CardState) => JSX.Element; diff --git a/packages/react-components/react-card/package.json b/packages/react-components/react-card/package.json index de3ea035cf7c5..04f35f48d5872 100644 --- a/packages/react-components/react-card/package.json +++ b/packages/react-components/react-card/package.json @@ -37,10 +37,12 @@ "@fluentui/react-button": "9.0.0-rc.13" }, "dependencies": { - "@griffel/react": "1.1.0", + "@fluentui/keyboard-keys": "9.0.0-rc.6", + "@fluentui/react-checkbox": "9.0.0-rc.5", "@fluentui/react-utilities": "9.0.0-rc.10", "@fluentui/react-tabster": "9.0.0-rc.13", "@fluentui/react-theme": "9.0.0-rc.9", + "@griffel/react": "1.1.0", "tslib": "^2.1.0" }, "peerDependencies": { diff --git a/packages/react-components/react-card/src/components/Card/Card.test.tsx b/packages/react-components/react-card/src/components/Card/Card.test.tsx index a41df2ed9c75d..9507c599e7e58 100644 --- a/packages/react-components/react-card/src/components/Card/Card.test.tsx +++ b/packages/react-components/react-card/src/components/Card/Card.test.tsx @@ -7,6 +7,9 @@ describe('Card', () => { isConformant({ Component: Card, displayName: 'Card', + requiredProps: { + selectable: true, + }, disabledTests: ['component-has-static-classname-exported'], }); diff --git a/packages/react-components/react-card/src/components/Card/Card.types.ts b/packages/react-components/react-card/src/components/Card/Card.types.ts index 8a19f0806600c..dacf32581c9a2 100644 --- a/packages/react-components/react-card/src/components/Card/Card.types.ts +++ b/packages/react-components/react-card/src/components/Card/Card.types.ts @@ -1,4 +1,13 @@ import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { Checkbox } from '@fluentui/react-checkbox'; +import * as React from 'react'; + +/** + * Data sent from the selection events on a selectable card. + */ +export type CardOnSelectData = { + selected: boolean; +}; /** * Slots available in the Card component. @@ -8,6 +17,11 @@ export type CardSlots = { * Root element of the component. */ root: Slot<'div'>; + + /** + * Checkbox slot used when `selectable` prop is enabled. + */ + select?: Slot; }; /** @@ -51,9 +65,36 @@ export type CardProps = ComponentProps & { * @default 'medium' */ size?: 'small' | 'medium' | 'large'; + + /** + * Enables selection of the card. + * + * @default false + */ + selectable?: boolean; + + /** + * Defines the controlled selected state of the card. + * + * @default false + */ + selected?: boolean; + + /** + * Defines whether the card is initially in a selected state or not when rendered. + * + * @default false + */ + defaultSelected?: boolean; + + /** + * Callback to be called when the selected state value changes. + */ + onCardSelect?: (event: React.MouseEvent | React.KeyboardEvent | React.ChangeEvent, data: CardOnSelectData) => void; }; /** * State used in rendering Card. */ -export type CardState = ComponentState & Required>; +export type CardState = ComponentState & + Required>; diff --git a/packages/react-components/react-card/src/components/Card/renderCard.tsx b/packages/react-components/react-card/src/components/Card/renderCard.tsx index e42fa9309da97..1670191c173b4 100644 --- a/packages/react-components/react-card/src/components/Card/renderCard.tsx +++ b/packages/react-components/react-card/src/components/Card/renderCard.tsx @@ -8,5 +8,10 @@ import type { CardSlots, CardState } from './Card.types'; export const renderCard_unstable = (state: CardState) => { const { slots, slotProps } = getSlots(state); - return ; + return ( + + {slotProps.root.children} + {slots.select && } + + ); }; diff --git a/packages/react-components/react-card/src/components/Card/useCard.ts b/packages/react-components/react-card/src/components/Card/useCard.ts index d284e36c1708f..4dc24fea5d55e 100644 --- a/packages/react-components/react-card/src/components/Card/useCard.ts +++ b/packages/react-components/react-card/src/components/Card/useCard.ts @@ -1,7 +1,9 @@ import * as React from 'react'; -import { getNativeElementProps } from '@fluentui/react-utilities'; +import { getNativeElementProps, resolveShorthand } from '@fluentui/react-utilities'; import type { CardProps, CardState } from './Card.types'; import { useFocusableGroup } from '@fluentui/react-tabster'; +import { Enter, Space } from '@fluentui/keyboard-keys'; +import { Checkbox } from '@fluentui/react-checkbox'; /** * Create the state required to render Card. @@ -13,7 +15,19 @@ import { useFocusableGroup } from '@fluentui/react-tabster'; * @param ref - reference to root HTMLElement of Card */ export const useCard_unstable = (props: CardProps, ref: React.Ref): CardState => { - const { appearance = 'filled', focusMode = 'off', orientation = 'vertical', size = 'medium' } = props; + const { + appearance = 'filled', + focusMode = 'off', + orientation = 'vertical', + size = 'medium', + select, + selectable = false, + selected, + defaultSelected = false, + onCardSelect, + } = props; + + const [checked, setChecked] = React.useState(selected ?? defaultSelected); const focusMap = { off: undefined, @@ -26,19 +40,56 @@ export const useCard_unstable = (props: CardProps, ref: React.Ref): tabBehavior: focusMap[focusMode], }); - const focusAttrs = focusMode !== 'off' ? { tabIndex: 0, ...groupperAttrs } : null; + const focusAttrs = focusMode !== 'off' ? { ...groupperAttrs } : null; + + const onChangeHandler = (event: React.MouseEvent | React.KeyboardEvent | React.ChangeEvent) => { + setChecked(!checked); + onCardSelect && onCardSelect(event, { selected: checked }); + }; + + const selectionAttrs = + selectable === true && focusMode === 'off' + ? { + onClick: onChangeHandler, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === Enter || event.key === Space) { + event.preventDefault(); + onChangeHandler(event); + } + }, + } + : null; + const selectSlotAttrs = + selectable === true && focusMode !== 'off' + ? { + onChange: onChangeHandler, + } + : null; return { appearance, + focusMode, orientation, size, + selectable, + selected: checked, - components: { root: 'div' }, + components: { root: 'div', select: Checkbox }, root: getNativeElementProps(props.as || 'div', { ref, role: 'group', + tabIndex: selectable || focusMode !== 'off' ? 0 : undefined, ...focusAttrs, + ...selectionAttrs, ...props, }), + select: selectable + ? resolveShorthand(select || {}, { + defaultProps: { + checked, + ...selectSlotAttrs, + }, + }) + : undefined, }; }; diff --git a/packages/react-components/react-card/src/components/Card/useCardStyles.ts b/packages/react-components/react-card/src/components/Card/useCardStyles.ts index 401cb07b028c2..622befd62a127 100644 --- a/packages/react-components/react-card/src/components/Card/useCardStyles.ts +++ b/packages/react-components/react-card/src/components/Card/useCardStyles.ts @@ -12,6 +12,7 @@ import { createFocusOutlineStyle } from '@fluentui/react-tabster'; */ export const cardClassNames: SlotClassNames = { root: 'fui-Card', + select: 'fui-Card__select', }; /** @@ -111,15 +112,6 @@ const useStyles = makeStyles({ [cardCSSVars.cardBorderRadiusVar]: tokens.borderRadiusLarge, }, - interactiveNoOutline: { - ':hover::after': { - ...shorthands.borderColor(tokens.colorTransparentStrokeInteractive), - }, - ':active::after': { - ...shorthands.borderColor(tokens.colorTransparentStrokeInteractive), - }, - }, - filledInteractive: { cursor: 'pointer', backgroundColor: tokens.colorNeutralBackground1, @@ -132,9 +124,24 @@ const useStyles = makeStyles({ ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover, boxShadow: tokens.shadow8, + + '::after': { + ...shorthands.borderColor(tokens.colorTransparentStrokeInteractive), + }, }, ':active': { backgroundColor: tokens.colorNeutralBackground1Pressed, + + '::after': { + ...shorthands.borderColor(tokens.colorTransparentStrokeInteractive), + }, + }, + }, + filledInteractiveSelected: { + backgroundColor: tokens.colorNeutralBackground1Selected, + + '::after': { + ...shorthands.borderColor(tokens.colorNeutralStroke1Selected), }, }, filled: { @@ -157,9 +164,24 @@ const useStyles = makeStyles({ ':hover': { backgroundColor: tokens.colorNeutralBackground2Hover, boxShadow: tokens.shadow8, + + '::after': { + ...shorthands.borderColor(tokens.colorTransparentStrokeInteractive), + }, }, ':active': { backgroundColor: tokens.colorNeutralBackground2Pressed, + + '::after': { + ...shorthands.borderColor(tokens.colorTransparentStrokeInteractive), + }, + }, + }, + filledAlternativeInteractiveSelected: { + backgroundColor: tokens.colorNeutralBackground2Selected, + + '::after': { + ...shorthands.borderColor(tokens.colorNeutralStroke1Selected), }, }, filledAlternative: { @@ -194,6 +216,13 @@ const useStyles = makeStyles({ }, }, }, + outlineInteractiveSelected: { + backgroundColor: tokens.colorTransparentBackgroundSelected, + + '::after': { + ...shorthands.borderColor(tokens.colorNeutralStroke1Selected), + }, + }, outline: { backgroundColor: tokens.colorTransparentBackground, boxShadow: 'none', @@ -213,9 +242,24 @@ const useStyles = makeStyles({ ':hover': { backgroundColor: tokens.colorSubtleBackgroundHover, + + '::after': { + ...shorthands.borderColor(tokens.colorTransparentStrokeInteractive), + }, }, ':active': { backgroundColor: tokens.colorSubtleBackgroundPressed, + + '::after': { + ...shorthands.borderColor(tokens.colorTransparentStrokeInteractive), + }, + }, + }, + subtleInteractiveSelected: { + backgroundColor: tokens.colorSubtleBackgroundSelected, + + '::after': { + ...shorthands.borderColor(tokens.colorNeutralStroke1Selected), }, }, subtle: { @@ -226,6 +270,12 @@ const useStyles = makeStyles({ ...shorthands.borderColor(tokens.colorTransparentStroke), }, }, + + select: { + position: 'absolute', + top: `var(${cardCSSVars.cardSizeVar})`, + right: `var(${cardCSSVars.cardSizeVar})`, + }, }); /** @@ -234,6 +284,18 @@ const useStyles = makeStyles({ export const useCardStyles_unstable = (state: CardState): CardState => { const styles = useStyles(); + const hasInteraction = + state.root.onClick || + state.root.onMouseUp || + state.root.onMouseDown || + state.root.onPointerUp || + state.root.onPointerDown || + state.root.onTouchStart || + state.root.onTouchEnd; + + const interactive = + (state.selectable && state.focusMode === 'off') || hasInteraction ? 'interactive' : 'nonInteractive'; + const orientationMap = { horizontal: styles.orientationHorizontal, vertical: styles.orientationVertical, @@ -245,31 +307,45 @@ export const useCardStyles_unstable = (state: CardState): CardState => { large: styles.sizeLarge, } as const; - const interactive = - state.root.onClick || - state.root.onMouseUp || - state.root.onMouseDown || - state.root.onPointerUp || - state.root.onPointerDown || - state.root.onTouchStart || - state.root.onTouchEnd; + const nonInteractiveAppearanceLookup = { + filled: styles.filled, + 'filled-alternative': styles.filledAlternative, + outline: styles.outline, + subtle: styles.subtle, + } as const; + + const interactiveAppearanceLookup = { + filled: styles.filledInteractive, + 'filled-alternative': styles.filledAlternativeInteractive, + outline: styles.outlineInteractive, + subtle: styles.subtleInteractive, + } as const; + + const appearanceLookup = { + nonInteractive: nonInteractiveAppearanceLookup, + interactive: interactiveAppearanceLookup, + } as const; + + const selectedLookup = { + filled: styles.filledInteractiveSelected, + 'filled-alternative': styles.filledAlternativeInteractiveSelected, + outline: styles.outlineInteractiveSelected, + subtle: styles.subtleInteractiveSelected, + } as const; state.root.className = mergeClasses( cardClassNames.root, styles.root, orientationMap[state.orientation], sizeMap[state.size], - state.appearance === 'filled' && styles.filled, - state.appearance === 'filled-alternative' && styles.filledAlternative, - state.appearance === 'outline' && styles.outline, - state.appearance === 'subtle' && styles.subtle, - interactive && state.appearance === 'filled' && styles.filledInteractive, - interactive && state.appearance === 'filled-alternative' && styles.filledAlternativeInteractive, - interactive && state.appearance === 'outline' && styles.outlineInteractive, - interactive && state.appearance === 'subtle' && styles.subtleInteractive, - interactive && state.appearance !== 'outline' && styles.interactiveNoOutline, + appearanceLookup[interactive][state.appearance], + state.selected && selectedLookup[state.appearance], state.root.className, ); + if (state.select) { + state.select.className = mergeClasses(cardClassNames.select, styles.select, state.select.className); + } + return state; }; diff --git a/packages/react-components/react-card/src/stories/Card.stories.tsx b/packages/react-components/react-card/src/stories/Card.stories.tsx index 3eda79cc383fb..de083217f0a42 100644 --- a/packages/react-components/react-card/src/stories/Card.stories.tsx +++ b/packages/react-components/react-card/src/stories/Card.stories.tsx @@ -6,6 +6,7 @@ export { Appearance } from './CardAppearance.stories'; export { FocusMode } from './CardFocusMode.stories'; export { Orientation } from './CardOrientation.stories'; export { Size } from './CardSize.stories'; +export { Selectable } from './CardSelectable.stories'; export default { title: 'Preview Components/Card', diff --git a/packages/react-components/react-card/src/stories/CardSelectable.stories.tsx b/packages/react-components/react-card/src/stories/CardSelectable.stories.tsx new file mode 100644 index 0000000000000..3bae3ff6c1584 --- /dev/null +++ b/packages/react-components/react-card/src/stories/CardSelectable.stories.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { action } from '@storybook/addon-actions'; +import { makeStyles, shorthands } from '@griffel/react'; +import { SampleCard, Title } from './SampleCard.stories'; + +const useStyles = makeStyles({ + container: { + display: 'flex', + flexDirection: 'column', + ...shorthands.padding('16px'), + ...shorthands.gap('16px'), + }, +}); + +export const Selectable = () => { + const styles = useStyles(); + + return ( +
+
+ + <SampleCard appearance="filled" selectable onCardSelect={action('onCardSelect - filled')} /> + </div> + <div> + <Title title="Filled Alternative" /> + <SampleCard + appearance="filled-alternative" + selectable + onCardSelect={action('onCardSelect - filled-alternative')} + /> + </div> + <div> + <Title title="Outline" /> + <SampleCard appearance="outline" selectable onCardSelect={action('onCardSelect - outline')} /> + </div> + <div> + <Title title="Subtle" /> + <SampleCard appearance="subtle" selectable onCardSelect={action('onCardSelect - subtle')} /> + </div> + </div> + ); +};