diff --git a/change/@fluentui-react-card-8ef7aceb-3860-4d43-aa98-9b830ff4c003.json b/change/@fluentui-react-card-8ef7aceb-3860-4d43-aa98-9b830ff4c003.json new file mode 100644 index 0000000000000..510e92113f22d --- /dev/null +++ b/change/@fluentui-react-card-8ef7aceb-3860-4d43-aa98-9b830ff4c003.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Added new `focusMode` property to control the focus behavior inside of the component", + "packageName": "@fluentui/react-card", + "email": "39736248+andrefcdias@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-card/Spec.md b/packages/react-components/react-card/Spec.md index bc09340ce3cec..52fdf4e3a37b2 100644 --- a/packages/react-components/react-card/Spec.md +++ b/packages/react-components/react-card/Spec.md @@ -102,17 +102,17 @@ Card goes for a more structural and generic approach to a card component and is #### API -| Property | Values | Default | Purpose | -| ----------- | ------------------------------------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------- | -| orientation | `vertical`, `horizontal` | `vertical` | Orientation of the card | -| size | `smallest`, `smaller`, `small`, `medium`, `large` | `medium` | Define the minimum size of the card. Smaller sizes only apply to horizontal card | -| scale | `fixed`, `auto-width`, `auto-height`, `auto`, `fluid-width`, `fluid-height`, `fluid` | `auto` | Manages how the card handles it's scaling depending on the content | -| appearance | `filled`, `filled-alternative`, `outline`, `subtle` | `filled` | Define the appearance of the card | -| selectable | boolean | false | Makes the card selectable by adding a checkbox to the _Actions_ area | -| selected | boolean | false | Set to `true` if card is selected | -| expandable | boolean | false | Allow card to expand to show whole content | -| disabled | boolean | false | Makes the card and card selection disabled (not propagated to children) | -| focusable | boolean \| 'noTab' \| 'tabExit' \| 'tabOnly' | false | Sets the focus behavior for the card. If `true`, the card will have the 'noTab' focus behavior. | +| Property | Values | Default | Purpose | +| ----------- | ------------------------------------------------------------------------------------ | ---------- | -------------------------------------------------------------------------------- | +| orientation | `vertical`, `horizontal` | `vertical` | Orientation of the card | +| size | `smallest`, `smaller`, `small`, `medium`, `large` | `medium` | Define the minimum size of the card. Smaller sizes only apply to horizontal card | +| scale | `fixed`, `auto-width`, `auto-height`, `auto`, `fluid-width`, `fluid-height`, `fluid` | `auto` | Manages how the card handles it's scaling depending on the content | +| appearance | `filled`, `filled-alternative`, `outline`, `subtle` | `filled` | Define the appearance of the card | +| selectable | boolean | false | Makes the card selectable by adding a checkbox to the _Actions_ area | +| selected | boolean | false | Set to `true` if card is selected | +| expandable | boolean | false | Allow card to expand to show whole content | +| disabled | boolean | false | Makes the card and card selection disabled (not propagated to children) | +| focusMode | `off`, `no-tab`, `tab-exit`, `tab-only` | `off` | Sets the focus behavior for the card. | #### `scale` property @@ -124,19 +124,23 @@ Card goes for a more structural and generic approach to a card component and is - `fluid-height`: `height` is set to `100%`. - `fluid`: `width` and `height` are set to `100%`. -#### `focusable` property +#### `focusMode` property -The three allowed focus behaviours (noTab, tabExit, tabOnly) map to the behaviors provided by Tabster. +The three allowed focus behaviours (`no-tab`, `tab-exit`, `tab-only`) map to the behaviors provided by Tabster. -- `noTab` (`trapFocus` in Tabster) +- `off` + + The card will not focusable. + +- `no-tab` (`trapFocus` in Tabster) This behaviour traps the focus inside of the Card when pressing the `Enter` key and will only release focus when pressing the `Escape` key. -- `tabExit` (`limited` in Tabster) +- `tab-exit` (`limited` in Tabster) This behaviour traps the focus inside of the Card when pressing the `Enter` key but will release focus when pressing the `Tab` key on the last inner element. -- `tabOnly` (`unlimited` in Tabster) +- `tab-only` (`unlimited` in Tabster) This behaviour will cycle through all elements inside of the Card when pressing the `Tab` key and then release focus after the last inner element. diff --git a/packages/react-components/react-card/e2e/Card.e2e.tsx b/packages/react-components/react-card/e2e/Card.e2e.tsx new file mode 100644 index 0000000000000..66c7bfa324e0d --- /dev/null +++ b/packages/react-components/react-card/e2e/Card.e2e.tsx @@ -0,0 +1,243 @@ +import * as React from 'react'; +import { mount } from '@cypress/react'; +import type {} from '@cypress/react'; +import { FluentProvider } from '@fluentui/react-provider'; +import { webLightTheme } from '@fluentui/react-theme'; +import { Button } from '@fluentui/react-button'; +import { Card, CardFooter, CardHeader } from '@fluentui/react-card'; +import type { CardProps } from '@fluentui/react-card'; + +const mountFluent = (element: JSX.Element) => { + mount({element}); +}; + +const CardSample = (props: CardProps) => { + const ASSET_URL = 'https://raw.githubusercontent.com/microsoft/fluentui/master/packages/react-card'; + + const powerpointLogoURL = ASSET_URL + '/assets/powerpoint_logo.svg'; + + return ( + <> +

+ Before +

+ + } + header={App Name} + description={Developer} + /> +
+ Donut chocolate bar oat cake. Dragée tiramisu lollipop bear claw. Marshmallow pastry jujubes toffee sugar + plum. +
+ + + + +
+

+ After +

+ + ); +}; + +describe('Card', () => { + describe('focus behaviors', () => { + describe('focusMode="off" (default)', () => { + it('should not be focusable', () => { + mountFluent(); + + cy.get('#before').focus(); + + cy.get('#card').should('not.be.focused'); + + cy.realPress('Tab'); + + cy.get('#card').should('not.be.focused'); + cy.get('#open-button').should('be.focused'); + }); + }); + + describe('focusMode="no-tab"', () => { + it('should be focusable', () => { + mountFluent(); + + const card = cy.get('#card'); + + card.should('not.be.focused'); + + card.focus(); + + card.should('be.focused'); + }); + + it('should focus inner elements on EnterKey press', () => { + mountFluent(); + + cy.get('#card').focus(); + + cy.realPress('Enter'); + + cy.get('#open-button').should('be.focused'); + }); + + it('should not focus inner elements on Tab press', () => { + mountFluent(); + + cy.get('#card').focus(); + + cy.realPress('Tab'); + + cy.get('#card').should('not.be.focused'); + cy.get('#after').should('be.focused'); + }); + + it('should trap focus', () => { + mountFluent(); + + cy.get('#open-button').focus(); + + cy.realPress('Tab'); + + cy.get('#open-button').should('not.be.focused'); + cy.get('#close-button').should('be.focused'); + + cy.realPress('Tab'); + + cy.get('#open-button').should('be.focused'); + cy.get('#close-button').should('not.be.focused'); + }); + + it('should focus parent on Esc press', () => { + mountFluent(); + + cy.get('#open-button').focus(); + + cy.realPress('Escape'); + + cy.get('#open-button').should('not.be.focused'); + cy.get('#card').should('be.focused'); + }); + }); + + describe('focusMode="tab-exit"', () => { + it('should be focusable', () => { + mountFluent(); + + const card = cy.get('#card'); + + card.should('not.be.focused'); + + card.focus(); + + card.should('be.focused'); + }); + + it('should focus inner elements on EnterKey press', () => { + mountFluent(); + + cy.get('#card').focus(); + + cy.realPress('Enter'); + + cy.get('#open-button').should('be.focused'); + }); + + it('should not focus inner elements on Tab press', () => { + mountFluent(); + + cy.get('#card').focus(); + + cy.realPress('Tab'); + + cy.get('#card').should('not.be.focused'); + cy.get('#after').should('be.focused'); + }); + + it('should exit on Tab press', () => { + mountFluent(); + + cy.get('#close-button').focus(); + + cy.realPress('Tab'); + + cy.get('#after').should('be.focused'); + }); + + it('should focus parent on Esc press', () => { + mountFluent(); + + cy.get('#card').focus().realPress('Enter'); + + cy.get('#open-button').should('be.focused'); + + cy.realPress('Escape'); + + cy.get('#card').should('be.focused'); + }); + }); + + describe('focusMode="tab-only"', () => { + it('should be focusable', () => { + mountFluent(); + + const card = cy.get('#card'); + + card.should('not.be.focused'); + + card.focus(); + + card.should('be.focused'); + }); + + it('should focus inner elements on EnterKey press', () => { + mountFluent(); + + cy.get('#card').focus(); + + cy.realPress('Enter'); + + cy.get('#open-button').should('be.focused'); + }); + + it('should focus inner elements on Tab press', () => { + mountFluent(); + + cy.get('#card').focus(); + + cy.realPress('Tab'); + + cy.get('#card').should('not.be.focused'); + cy.get('#open-button').should('be.focused'); + }); + + it('should exit on Tab press', () => { + mountFluent(); + + cy.get('#close-button').focus(); + + cy.realPress('Tab'); + + cy.get('#after').should('be.focused'); + }); + + it('should focus parent on Esc press', () => { + mountFluent(); + + cy.get('#card').focus().realPress('Enter'); + + cy.get('#open-button').should('be.focused'); + + cy.realPress('Escape'); + + cy.get('#card').should('be.focused'); + }); + }); + }); +}); diff --git a/packages/react-components/react-card/e2e/tsconfig.json b/packages/react-components/react-card/e2e/tsconfig.json new file mode 100644 index 0000000000000..c5fb83855c725 --- /dev/null +++ b/packages/react-components/react-card/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "isolatedModules": false, + "noEmit": true, + "types": ["node", "cypress", "cypress-storybook/cypress", "cypress-real-events"], + "lib": ["ES2019", "dom"] + }, + "include": ["**/*.ts", "**/*.tsx"] +} 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 54f5eb9606854..bffed6d58a19c 100644 --- a/packages/react-components/react-card/etc/react-card.api.md +++ b/packages/react-components/react-card/etc/react-card.api.md @@ -23,6 +23,7 @@ export const cardClassNames: SlotClassNames; // @public (undocumented) export type CardCommons = { appearance: 'filled' | 'filled-alternative' | 'outline' | 'subtle'; + focusMode: 'off' | 'no-tab' | 'tab-exit' | 'tab-only'; }; // @public diff --git a/packages/react-components/react-card/package.json b/packages/react-components/react-card/package.json index f68531c558f35..993ff48fcae8e 100644 --- a/packages/react-components/react-card/package.json +++ b/packages/react-components/react-card/package.json @@ -17,6 +17,7 @@ "bundle-size": "bundle-size measure", "clean": "just-scripts clean", "code-style": "just-scripts code-style", + "e2e": "e2e", "just": "just-scripts", "lint": "just-scripts lint", "start": "yarn storybook", 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 a5325025499b0..677601246adcd 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 @@ -6,6 +6,28 @@ export type CardSlots = { export type CardCommons = { appearance: 'filled' | 'filled-alternative' | 'outline' | 'subtle'; + + /** + * Sets the focus behavior for the card. If `true`, the card will use the `noTab` focus behavior. + * + * `off` + * The card will not focusable. + * + * `no-tab` + * This behaviour traps the focus inside of the Card when pressing the Enter key and will only release focus when + * pressing the Escape key. + * + * `tab-exit` + * This behaviour traps the focus inside of the Card when pressing the Enter key but will release focus when pressing + * the Tab key on the last inner element. + * + * `tab-only` + * This behaviour will cycle through all elements inside of the Card when pressing the Tab key and then release focus + * after the last inner element. + * + * @defaultvalue off + */ + focusMode: 'off' | 'no-tab' | 'tab-exit' | 'tab-only'; }; /** diff --git a/packages/react-components/react-card/src/components/Card/__snapshots__/Card.test.tsx.snap b/packages/react-components/react-card/src/components/Card/__snapshots__/Card.test.tsx.snap index a6c7a2e12934b..2216d3a145c54 100644 --- a/packages/react-components/react-card/src/components/Card/__snapshots__/Card.test.tsx.snap +++ b/packages/react-components/react-card/src/components/Card/__snapshots__/Card.test.tsx.snap @@ -4,7 +4,7 @@ exports[`Card renders a default state 1`] = `
Default Card 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 0cf30ee3bd6ab..9c90f02ca4bf2 100644 --- a/packages/react-components/react-card/src/components/Card/useCard.ts +++ b/packages/react-components/react-card/src/components/Card/useCard.ts @@ -13,18 +13,28 @@ 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' } = props; + + const focusMap = { + off: undefined, + 'no-tab': 'limitedTrapFocus', + 'tab-exit': 'limited', + 'tab-only': 'unlimited', + } as const; + const groupperAttrs = useFocusableGroup({ - tabBehavior: 'limitedTrapFocus', + tabBehavior: focusMap[focusMode], }); - const { appearance = 'filled' } = props; return { appearance, + focusMode, components: { root: 'div' }, root: getNativeElementProps(props.as || 'div', { ref, role: 'group', + tabIndex: focusMode !== 'off' ? 0 : undefined, ...groupperAttrs, ...props, }), 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 524496d0ef40e..4c3ae00c62ca3 100644 --- a/packages/react-components/react-card/src/stories/Card.stories.tsx +++ b/packages/react-components/react-card/src/stories/Card.stories.tsx @@ -3,6 +3,7 @@ import descriptionMd from './CardDescription.md'; export { Default } from './CardDefault.stories'; export { Appearance } from './CardAppearance.stories'; +export { FocusMode } from './CardFocusMode.stories'; export default { title: 'Preview Components/Card', diff --git a/packages/react-components/react-card/src/stories/CardFocusMode.stories.tsx b/packages/react-components/react-card/src/stories/CardFocusMode.stories.tsx new file mode 100644 index 0000000000000..042cf84753ca1 --- /dev/null +++ b/packages/react-components/react-card/src/stories/CardFocusMode.stories.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; + +import { Title3, Text } from '@fluentui/react-text'; + +import { makeStyles, shorthands } from '@griffel/react'; +import { SampleCard } from './SampleCard.stories'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + ...shorthands.gap('30px'), + }, +}); + +export const FocusMode = () => { + const styles = useStyles(); + + return ( +
+
+ 'off' (Default) + + The contents might still be focusable, but the Card won't manage the focus of its contents or be focusable. + +
+ +
+ 'no-tab' + + The Card will be focusable and trap the focus. You can use Tab to navigate between the contents and escaping + focus only by pressing the Esc key. + +
+ +
+ 'tab-exit' + The Card will be focusable and trap the focus, but release it on an Esc or Tab key press. +
+ +
+ 'tab-only' + + The Card will not trap focus but will still be focusable and allow Tab navigation of its contents. + +
+ +
+ ); +}; + +FocusMode.parameters = { + docs: { + description: { + story: + 'Cards can be focusable and manage the focus of their contents in several different strategies. ' + + 'Using the `focusMode` prop, we can achieve the following:', + }, + }, +}; diff --git a/packages/react-components/react-card/tsconfig.json b/packages/react-components/react-card/tsconfig.json index 1941a041d46c1..9087bac77cc8d 100644 --- a/packages/react-components/react-card/tsconfig.json +++ b/packages/react-components/react-card/tsconfig.json @@ -20,6 +20,9 @@ }, { "path": "./.storybook/tsconfig.json" + }, + { + "path": "./e2e/tsconfig.json" } ] }