Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
Expand Up @@ -13,6 +13,7 @@ exports[`CatalogTile href renders properly 1`] = `
>
<div
class="pf-c-card__title catalog-tile-pf-header"
id="test-href-title"
>
<div
class="catalog-tile-pf-title"
Expand Down Expand Up @@ -111,6 +112,7 @@ exports[`CatalogTile renders properly 1`] = `
</div>
<div
class="pf-c-card__title catalog-tile-pf-header"
id="single-badge-test-title"
>
<div
class="catalog-tile-pf-title"
Expand Down Expand Up @@ -215,6 +217,7 @@ exports[`CatalogTile renders properly 1`] = `
</div>
<div
class="pf-c-card__title catalog-tile-pf-header"
id="multi-badge-test-title"
>
<div
class="catalog-tile-pf-title"
Expand Down Expand Up @@ -291,6 +294,7 @@ exports[`CatalogTile renders properly 1`] = `
</div>
<div
class="pf-c-card__title catalog-tile-pf-header"
id="test-iconClass-title"
>
<div
class="catalog-tile-pf-title"
Expand Down Expand Up @@ -364,6 +368,7 @@ exports[`CatalogTile renders properly 1`] = `
</div>
<div
class="pf-c-card__title catalog-tile-pf-header"
id="tile-footer-test-title"
>
<div
class="catalog-tile-pf-title"
Expand Down Expand Up @@ -442,6 +447,7 @@ exports[`CatalogTile renders properly 1`] = `
</div>
<div
class="pf-c-card__title catalog-tile-pf-header"
id="custom-icon-svg-test-title"
>
<div
class="catalog-tile-pf-title"
Expand Down
53 changes: 53 additions & 0 deletions packages/react-core/src/components/Card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,28 @@ export interface CardProps extends React.HTMLProps<HTMLElement>, OUIAProps {
isPlain?: boolean;
/** Flag indicating if a card is expanded. Modifies the card to be expandable. */
isExpanded?: boolean;
/** Flag indicating that the card should render a hidden input to make it selectable */
hasSelectableInput?: boolean;
/** Aria label to apply to the selectable input if one is rendered */
selectableInputAriaLabel?: string;
/** Callback that executes when the selectable input is changed */
onSelectableInputChange?: (labelledBy: string, event: React.FormEvent<HTMLInputElement>) => void;
}

interface CardContextProps {
cardId: string;
registerTitleId: (id: string) => void;
isExpanded: boolean;
}

interface AriaProps {
'aria-label'?: string;
'aria-labelledby'?: string;
}

export const CardContext = React.createContext<Partial<CardContextProps>>({
cardId: '',
registerTitleId: () => {},
isExpanded: false
});

Expand All @@ -67,10 +80,16 @@ export const Card: React.FunctionComponent<CardProps> = ({
isPlain = false,
ouiaId,
ouiaSafe = true,
hasSelectableInput = false,
selectableInputAriaLabel,
onSelectableInputChange = () => {},
...props
}: CardProps) => {
const Component = component as any;
const ouiaProps = useOUIAProps(Card.displayName, ouiaId, ouiaSafe);
const [titleId, setTitleId] = React.useState('');
const [ariaProps, setAriaProps] = React.useState<AriaProps>();

if (isCompact && isLarge) {
// eslint-disable-next-line no-console
console.warn('Card: Cannot use isCompact with isLarge. Defaulting to isCompact');
Expand All @@ -90,13 +109,47 @@ export const Card: React.FunctionComponent<CardProps> = ({
return '';
};

const containsCardTitleChildRef = React.useRef(false);

const registerTitleId = (id: string) => {
setTitleId(id);
containsCardTitleChildRef.current = !!id;
};

React.useEffect(() => {
if (selectableInputAriaLabel) {
setAriaProps({ 'aria-label': selectableInputAriaLabel });
} else if (titleId) {
setAriaProps({ 'aria-labelledby': titleId });
} else if (hasSelectableInput && !containsCardTitleChildRef.current) {
setAriaProps({});
// eslint-disable-next-line no-console
console.warn(
'If no CardTitle component is passed as a child of Card the selectableInputAriaLabel prop must be passed'
);
}
}, [hasSelectableInput, selectableInputAriaLabel, titleId]);

return (
<CardContext.Provider
value={{
cardId: id,
registerTitleId,
isExpanded
}}
>
{hasSelectableInput && (
<input
className="pf-screen-reader"
id={`${id}-input`}
{...ariaProps}
type="checkbox"
checked={isSelected}
onChange={event => onSelectableInputChange(id, event)}
disabled={isDisabledRaised}
tabIndex={-1}
/>
)}
<Component
id={id}
className={css(
Expand Down
12 changes: 11 additions & 1 deletion packages/react-core/src/components/Card/CardTitle.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { css } from '@patternfly/react-styles';
import styles from '@patternfly/react-styles/css/components/Card/card';
import { CardContext } from './Card';

export interface CardTitleProps extends React.HTMLProps<HTMLDivElement> {
/** Content rendered inside the CardTitle */
Expand All @@ -17,9 +18,18 @@ export const CardTitle: React.FunctionComponent<CardTitleProps> = ({
component = 'div',
...props
}: CardTitleProps) => {
const { cardId, registerTitleId } = React.useContext(CardContext);
const Component = component as any;
const titleId = cardId ? `${cardId}-title` : '';

React.useEffect(() => {
registerTitleId(titleId);

return () => registerTitleId('');
}, [registerTitleId, titleId]);

return (
<Component className={css(styles.cardTitle, className)} {...props}>
<Component className={css(styles.cardTitle, className)} id={titleId || undefined} {...props}>
{children}
</Component>
);
Expand Down
91 changes: 90 additions & 1 deletion packages/react-core/src/components/Card/__tests__/Card.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import React from 'react';

import { render, screen } from '@testing-library/react';
import { Card } from '../Card';
import '@testing-library/jest-dom';

import { Card, CardContext } from '../Card';

describe('Card', () => {
test('renders with PatternFly Core styles', () => {
Expand Down Expand Up @@ -118,4 +121,90 @@ describe('Card', () => {
render(<Card isLarge isCompact />);
expect(consoleWarnMock).toHaveBeenCalled();
});

test('card renders with a hidden input to improve a11y when hasSelectableInput is passed', () => {
render(<Card isSelectable hasSelectableInput />);

const selectableInput = screen.getByRole('checkbox', { hidden: true });

expect(selectableInput).toBeInTheDocument();
});

test('card does not render the hidden input when hasSelectableInput is not passed', () => {
render(<Card isSelectable />);

const selectableInput = screen.queryByRole('checkbox', { hidden: true });

expect(selectableInput).not.toBeInTheDocument();
});

test('card warns when hasSelectableInput is passed without selectableInputAriaLabel or a card title', () => {
const consoleWarnMock = jest.fn();
global.console = { warn: consoleWarnMock } as any;

render(<Card isSelectable hasSelectableInput />);

const selectableInput = screen.getByRole('checkbox', { hidden: true });

expect(consoleWarnMock).toBeCalled();
expect(selectableInput).toHaveAccessibleName('');
});

test('card applies selectableInputAriaLabel to the hidden input', () => {
render(<Card isSelectable hasSelectableInput selectableInputAriaLabel="Input label test" />);

const selectableInput = screen.getByRole('checkbox', { hidden: true });

expect(selectableInput).toHaveAccessibleName('Input label test');
});

test('card applies the supplied card title as the aria label of the hidden input', () => {

// this component is used to mock the CardTitle's title registry behavior to keep this a pure unit test
const MockCardTitle = ({ children }) => {
const { registerTitleId } = React.useContext(CardContext);
const id = 'card-title-id';

React.useEffect(() => {
registerTitleId(id);
});

return <div id={id}>{children}</div>;
};

render(
<Card id="card" isSelectable hasSelectableInput>
<MockCardTitle>Card title from title component</MockCardTitle>
</Card>
);

const selectableInput = screen.getByRole('checkbox', { hidden: true });

expect(selectableInput).toHaveAccessibleName('Card title from title component');
});

test('card prioritizes selectableInputAriaLabel over card title labelling via card title', () => {

// this component is used to mock the CardTitle's title registry behavior to keep this a pure unit test
const MockCardTitle = ({ children }) => {
const { registerTitleId } = React.useContext(CardContext);
const id = 'card-title-id';

React.useEffect(() => {
registerTitleId(id);
});

return <div id={id}>{children}</div>;
};

render(
<Card id="card" isSelectable hasSelectableInput selectableInputAriaLabel="Input label test">
<MockCardTitle>Card title from title component</MockCardTitle>
</Card>
);

const selectableInput = screen.getByRole('checkbox', { hidden: true });

expect(selectableInput).toHaveAccessibleName('Input label test');
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { CardTitle } from '../CardTitle';
import { CardContext } from '../Card';

describe('CardTitle', () => {
test('renders with PatternFly Core styles', () => {
Expand All @@ -19,4 +20,16 @@ describe('CardTitle', () => {
render(<CardTitle data-testid={testId} />);
expect(screen.getByTestId(testId)).toBeInTheDocument();
});

test('calls the registerTitleId function provided by the CardContext with the generated title id', () => {
const mockRegisterTitleId = jest.fn();

render(
<CardContext.Provider value={{ cardId: 'card', registerTitleId: mockRegisterTitleId }}>
<CardTitle>text</CardTitle>
</CardContext.Provider>
);

expect(mockRegisterTitleId).toHaveBeenCalledWith('card-title');
});
});
17 changes: 17 additions & 0 deletions packages/react-core/src/components/Card/examples/Card.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,23 @@ import pfLogoSmall from './pf-logo-small.svg';
```ts file='./CardSelectable.tsx'
```

### Selectable accessibility highlight

This example demonstrates how the `hasSelectableInput` and `onSelectableInputChange` props improve accessibility for selectable cards.

The first card sets `hasSelectableInput` to true, which renders a checkbox input that is only visible to, and navigable by, screen readers. This input communicates to assistive technology users that a card is selectable, and if so, it communicates the current selection state as well.

By default this input will have an aria-label that corresponds to the title given to the card if using the card title component. If you don't use the card title component in your selectable card, you must pass a custom aria-label for this input using the `selectableInputAriaLabel` prop.

The first card also (by passing an onchange callback to `onSelectableInputChange`) enables the selection/deselection of the associated card by checking/unchecking the checkbox input.

The second card does not set `hasSelectableInput` to true, so the input is not rendered. It does not communicate to screen reader users that it is selectable or if it is currently selected.

To best understand this example it is encouraged that you navigate both of these cards using a screen reader.

```ts file='./CardSelectableA11yHighlight.tsx'
```

### With heading element

```ts file='./CardWithHeadingElement.tsx'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export const CardLegacySelectable: React.FunctionComponent = () => {
setSelected(newSelected);
};

const onChange = (labelledById: string, _event: React.FormEvent<HTMLInputElement>) => {
const newSelected = labelledById === selected ? null : labelledById;
setSelected(newSelected);
};

const onToggle = (isOpen: boolean, event: any) => {
event.stopPropagation();
setIsKebabOpen(isOpen);
Expand Down Expand Up @@ -65,8 +70,10 @@ export const CardLegacySelectable: React.FunctionComponent = () => {
id="legacy-first-card"
onKeyDown={onKeyDown}
onClick={onClick}
onSelectableInputChange={onChange}
isSelectable
isSelected={selected === 'legacy-first-card'}
hasSelectableInput
>
<CardHeader>
<CardActions>
Expand All @@ -80,18 +87,20 @@ export const CardLegacySelectable: React.FunctionComponent = () => {
/>
</CardActions>
</CardHeader>
<CardTitle>First card</CardTitle>
<CardTitle>First legacy selectable card</CardTitle>
<CardBody>This is a selectable card. Click me to select me. Click again to deselect me.</CardBody>
</Card>
<br />
<Card
id="legacy-second-card"
onKeyDown={onKeyDown}
onClick={onClick}
onSelectableInputChange={onChange}
isSelectable
isSelected={selected === 'legacy-second-card'}
hasSelectableInput
>
<CardTitle>Second card</CardTitle>
<CardTitle>Second legacy selectable card</CardTitle>
<CardBody>This is a selectable card. Click me to select me. Click again to deselect me.</CardBody>
</Card>
</>
Expand Down
Loading