Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b311e06
feat: add focusable prop
theerebuss Apr 2, 2022
90ffeeb
chore: add type safe approach
theerebuss Apr 2, 2022
8d5cb1e
chore: add API files
theerebuss Apr 5, 2022
543d0e0
fix: add missing tabindex for focusable cards
theerebuss Apr 5, 2022
1054f5a
feat: add focus story
theerebuss Apr 5, 2022
9a5e04b
fix: lint errors
theerebuss Apr 5, 2022
8fe5c64
chore: update snapshots
theerebuss Apr 5, 2022
80d6c7a
Merge branch 'master' into card-focusable
theerebuss Apr 12, 2022
3b2b94a
chore: leverage CardSample to avoid duplication
theerebuss Apr 13, 2022
160574c
chore: add e2e focus tests
theerebuss Apr 14, 2022
e90464b
chore: remove unused import
theerebuss Apr 26, 2022
bd89c2a
Update packages/react-card/src/stories/CardFocusable.stories.tsx
theerebuss Apr 26, 2022
c222356
chore: rename `focusable` camelCase type to kebab-case
theerebuss Apr 26, 2022
bf2970a
chore: add documentation to `focusable`
theerebuss Apr 26, 2022
b5f7f26
fix: correct CardCommons' docs naming
theerebuss Apr 27, 2022
500b150
feat: add alternative `focusMode` approach
theerebuss Apr 27, 2022
2d55a67
fix: use backticks for values in the spec
theerebuss Apr 27, 2022
a2804ba
fix: change docs to respect max line length
theerebuss Apr 27, 2022
d192755
chore: move card to react-components
theerebuss Apr 29, 2022
be9688a
Merge branch 'master' into card-focusable
theerebuss Apr 29, 2022
bc54b30
fix: remove unintended change to react-button
theerebuss Apr 29, 2022
1efb5f4
fix: address merging issues
theerebuss Apr 29, 2022
7427282
chore: use `as const` for the prop map
theerebuss May 4, 2022
77b469d
chore: remove unused imports
theerebuss May 4, 2022
e6a3cd3
chore: change story name to match prop
theerebuss May 5, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"dependentChangeType": "patch"
}
36 changes: 20 additions & 16 deletions packages/react-components/react-card/Spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down
243 changes: 243 additions & 0 deletions packages/react-components/react-card/e2e/Card.e2e.tsx
Original file line number Diff line number Diff line change
@@ -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(<FluentProvider theme={webLightTheme}>{element}</FluentProvider>);
};

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 (
<>
<p tabIndex={0} id="before">
Before
</p>
<Card id="card" {...props}>
<CardHeader
image={<img src={powerpointLogoURL} alt="Microsoft PowerPoint logo" />}
header={<b>App Name</b>}
description={<span>Developer</span>}
/>
<div>
Donut chocolate bar oat cake. Dragée tiramisu lollipop bear claw. Marshmallow pastry jujubes toffee sugar
plum.
</div>
<CardFooter>
<Button id="open-button" onClick={alert} appearance="primary">
Open
</Button>
<Button id="close-button" appearance="outline">
Close
</Button>
</CardFooter>
</Card>
<p tabIndex={0} id="after">
After
</p>
</>
);
};

describe('Card', () => {
describe('focus behaviors', () => {
describe('focusMode="off" (default)', () => {
it('should not be focusable', () => {
mountFluent(<CardSample />);

cy.get('#before').focus();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you seem to use these selectors multiple times, would be worth hoisting them to variables

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duplication is intentional for readability purposes as hardcoded values makes the test clearer (less need to hop around to understand what it is doing).


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(<CardSample focusMode="no-tab" />);

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(<CardSample focusMode="no-tab" />);

cy.get('#card').focus();

cy.realPress('Enter');

cy.get('#open-button').should('be.focused');
});

it('should not focus inner elements on Tab press', () => {
mountFluent(<CardSample focusMode="no-tab" />);

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(<CardSample focusMode="no-tab" />);

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(<CardSample focusMode="no-tab" />);

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(<CardSample focusMode="tab-exit" />);

const card = cy.get('#card');

card.should('not.be.focused');

card.focus();

card.should('be.focused');
});
Comment on lines +130 to +140
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the focusable card test could be in another test suite. You could use forEach to iterate through different behaviours and only write the test once. You can take a look at Popover e2e tests where it goes through controlled and uncontrolled.

Sometimes I also think duping tests can be nice because reusable tests are always a bit less readable. So up to you if you want to do this or not

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to go with verbosity over efficiency for tests. Tests should be as readable as possible overall and the less "magic" we add to them, the better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the ancient wisdom says: "it depends" :D


it('should focus inner elements on EnterKey press', () => {
mountFluent(<CardSample focusMode="tab-exit" />);

cy.get('#card').focus();

cy.realPress('Enter');

cy.get('#open-button').should('be.focused');
});

it('should not focus inner elements on Tab press', () => {
mountFluent(<CardSample focusMode="tab-exit" />);

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(<CardSample focusMode="tab-exit" />);

cy.get('#close-button').focus();

cy.realPress('Tab');

cy.get('#after').should('be.focused');
});

it('should focus parent on Esc press', () => {
mountFluent(<CardSample focusMode="tab-exit" />);

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(<CardSample focusMode="tab-only" />);

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(<CardSample focusMode="tab-only" />);

cy.get('#card').focus();

cy.realPress('Enter');

cy.get('#open-button').should('be.focused');
});

it('should focus inner elements on Tab press', () => {
mountFluent(<CardSample focusMode="tab-only" />);

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(<CardSample focusMode="tab-only" />);

cy.get('#close-button').focus();

cy.realPress('Tab');

cy.get('#after').should('be.focused');
});

it('should focus parent on Esc press', () => {
mountFluent(<CardSample focusMode="tab-only" />);

cy.get('#card').focus().realPress('Enter');

cy.get('#open-button').should('be.focused');

cy.realPress('Escape');

cy.get('#card').should('be.focused');
});
});
});
});
10 changes: 10 additions & 0 deletions packages/react-components/react-card/e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const cardClassNames: SlotClassNames<CardSlots>;
// @public (undocumented)
export type CardCommons = {
appearance: 'filled' | 'filled-alternative' | 'outline' | 'subtle';
focusMode: 'off' | 'no-tab' | 'tab-exit' | 'tab-only';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙌

};

// @public
Expand Down
1 change: 1 addition & 0 deletions packages/react-components/react-card/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exports[`Card renders a default state 1`] = `
<div>
<div
class="fui-Card"
data-tabster="{\\"groupper\\":{\\"tabbability\\":2}}"
data-tabster="{\\"groupper\\":{}}"
role="group"
>
Default Card
Expand Down
Loading