Skip to content

Commit

Permalink
feat(Card) implement new selectable/clickable card design (#9092)
Browse files Browse the repository at this point in the history
* feat(Card) implement new selectable/clickable card design

* selectable and clickable changes

* add selectableVariant, refactor markup

* add clickable & selectable

* Updated functionality

* update examples, add functionality

* Update selectableActions api

* Added clickable and selectable example

* Updated tests

* Updated legacy verbiage and logic to render selectable cards

* Added prop for link target

* Updated example markup

* Updated example order

---------

Co-authored-by: Eric Olkowski <thatblindgeye@gmail.com>
  • Loading branch information
Dominik-Petrik and thatblindgeye committed May 18, 2023
1 parent c3fe2fb commit c3cf823
Show file tree
Hide file tree
Showing 15 changed files with 497 additions and 158 deletions.
43 changes: 34 additions & 9 deletions packages/react-core/src/components/Card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ export interface CardProps extends React.HTMLProps<HTMLElement>, OUIAProps {
isCompact?: boolean;
/** Modifies the card to include selectable styling */
isSelectable?: boolean;
/** Specifies the card is selectable, and applies the new raised styling on hover and select */
/** @deprecated Specifies the card is selectable, and applies raised styling on hover and select */
isSelectableRaised?: boolean;
/** Modifies the card to include selected styling */
isSelected?: boolean;
/** Modifies a raised selectable card to have disabled styling */
/** Modifies the card to include clickable styling */
isClickable?: boolean;
/** Modifies a clickable or selectable card to have disabled styling. */
isDisabled?: boolean;
/** @deprecated Modifies a raised selectable card to have disabled styling */
isDisabledRaised?: boolean;
/** Modifies the card to include flat styling */
isFlat?: boolean;
Expand All @@ -34,11 +38,11 @@ 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 */
/** @deprecated 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 */
/** @deprecated Aria label to apply to the selectable input if one is rendered */
selectableInputAriaLabel?: string;
/** Callback that executes when the selectable input is changed */
/** @deprecated Callback that executes when the selectable input is changed */
onSelectableInputChange?: (event: React.FormEvent<HTMLInputElement>, labelledBy: string) => void;
/** Value to overwrite the randomly generated data-ouia-component-id.*/
ouiaId?: number | string;
Expand All @@ -50,6 +54,9 @@ interface CardContextProps {
cardId: string;
registerTitleId: (id: string) => void;
isExpanded: boolean;
isClickable: boolean;
isSelectable: boolean;
isDisabled: boolean;
}

interface AriaProps {
Expand All @@ -60,7 +67,10 @@ interface AriaProps {
export const CardContext = React.createContext<Partial<CardContextProps>>({
cardId: '',
registerTitleId: () => {},
isExpanded: false
isExpanded: false,
isClickable: false,
isSelectable: false,
isDisabled: false
});

export const Card: React.FunctionComponent<CardProps> = ({
Expand All @@ -70,6 +80,8 @@ export const Card: React.FunctionComponent<CardProps> = ({
component = 'div',
isCompact = false,
isSelectable = false,
isClickable = false,
isDisabled = false,
isSelectableRaised = false,
isSelected = false,
isDisabledRaised = false,
Expand Down Expand Up @@ -104,9 +116,18 @@ export const Card: React.FunctionComponent<CardProps> = ({
if (isSelectableRaised) {
return css(styles.modifiers.selectableRaised, isSelected && styles.modifiers.selectedRaised);
}
if (isSelectable && isClickable) {
return css(styles.modifiers.selectable, styles.modifiers.clickable, isSelected && styles.modifiers.current);
}

if (isSelectable) {
return css(styles.modifiers.selectable, isSelected && styles.modifiers.selected);
}

if (isClickable) {
return css(styles.modifiers.clickable, isSelected && styles.modifiers.selected);
}

return '';
};

Expand Down Expand Up @@ -136,7 +157,10 @@ export const Card: React.FunctionComponent<CardProps> = ({
value={{
cardId: id,
registerTitleId,
isExpanded
isExpanded,
isClickable,
isSelectable,
isDisabled
}}
>
{hasSelectableInput && (
Expand All @@ -146,7 +170,7 @@ export const Card: React.FunctionComponent<CardProps> = ({
{...ariaProps}
type="checkbox"
checked={isSelected}
onChange={event => onSelectableInputChange(event, id)}
onChange={(event) => onSelectableInputChange(event, id)}
disabled={isDisabledRaised}
tabIndex={-1}
/>
Expand All @@ -163,9 +187,10 @@ export const Card: React.FunctionComponent<CardProps> = ({
isFullHeight && styles.modifiers.fullHeight,
isPlain && styles.modifiers.plain,
getSelectableModifiers(),
isDisabled && styles.modifiers.disabled,
className
)}
tabIndex={isSelectable || isSelectableRaised ? '0' : undefined}
tabIndex={isSelectableRaised ? '0' : undefined}
{...props}
{...ouiaProps}
>
Expand Down
96 changes: 94 additions & 2 deletions packages/react-core/src/components/Card/CardHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import styles from '@patternfly/react-styles/css/components/Card/card';
import { CardContext } from './Card';
import { CardHeaderMain } from './CardHeaderMain';
import { CardActions } from './CardActions';
import { CardSelectableActions } from './CardSelectableActions';
import { Button } from '../Button';
import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon';
import { Radio } from '../Radio';
import { Checkbox } from '../Checkbox';

export interface CardHeaderActionsObject {
/** Actions of the card header */
Expand All @@ -16,13 +19,44 @@ export interface CardHeaderActionsObject {
className?: string;
}

export interface CardHeaderSelectableActionsObject {
/** Determines the type of input to be used for a selectable card. */
variant?: 'single' | 'multiple';
/** Flag indicating that the actions have no offset */
hasNoOffset?: boolean;
/** Additional classes added to the selectable actions wrapper */
className?: string;
/** ID passed to the selectable or clickable input */
selectableActionId: string;
/** Adds an accessible label to the selectable or clickable input */
selectableActionAriaLabel?: string;
/** Adds an accessible label to the selectable or clickable input by passing in a
* space separated list of id's.
*/
selectableActionAriaLabelledby?: string;
/** Callback for when a selectable card input changes */
onChange?: (event: React.FormEvent<HTMLInputElement>, checked: boolean) => void;
/** Action to call when clickable card is clicked */
onClickAction?: (event: React.FormEvent<HTMLInputElement> | React.MouseEvent) => void;
/** Link to navigate to when clickable card is clicked */
to?: string;
/** Flag to indicate whether a clickable card's link should open in a new tab/window. */
isExternalLink?: boolean;
/** Name for the input element of a clickable or selectable card. */
name?: string;
/** Flag indicating whether the selectable card input is checked */
isChecked?: boolean;
}

export interface CardHeaderProps extends React.HTMLProps<HTMLDivElement> {
/** Content rendered inside the card header */
children?: React.ReactNode;
/** Additional classes added to the card header */
className?: string;
/** Actions of the card header */
actions?: CardHeaderActionsObject;
/** Selectable actions of the card header */
selectableActions?: CardHeaderSelectableActionsObject;
/** ID of the card header. */
id?: string;
/** Callback expandable card */
Expand All @@ -37,14 +71,15 @@ export const CardHeader: React.FunctionComponent<CardHeaderProps> = ({
children,
className,
actions,
selectableActions,
id,
onExpand,
toggleButtonProps,
isToggleRightAligned,
...props
}: CardHeaderProps) => (
<CardContext.Consumer>
{({ cardId }) => {
{({ cardId, isClickable, isSelectable, isDisabled: isCardDisabled }) => {
const cardHeaderToggle = (
<div className={css(styles.cardHeaderToggle)}>
<Button
Expand All @@ -62,14 +97,71 @@ export const CardHeader: React.FunctionComponent<CardHeaderProps> = ({
</div>
);

if (actions?.actions && !(isClickable && isSelectable)) {
// eslint-disable-next-line no-console
console.warn(
`${
isClickable ? 'Clickable' : 'Selectable'
} only cards should not contain any other actions. If you wish to include additional actions, use a clickable and selectable card.`
);
}

const handleActionClick = (event: React.FormEvent<HTMLInputElement> | React.MouseEvent) => {
if (isClickable) {
if (selectableActions?.onClickAction) {
selectableActions.onClickAction(event);
} else if (selectableActions?.to) {
window.open(selectableActions.to, selectableActions.isExternalLink ? '_blank' : '_self');
}
}
};

const getClickableSelectableProps = () => {
const baseProps = {
className: 'pf-m-standalone',
inputClassName: isClickable && !isSelectable && 'pf-v5-screen-reader',
label: <></>,
'aria-label': selectableActions.selectableActionAriaLabel,
'aria-labelledby': selectableActions.selectableActionAriaLabelledby,
id: selectableActions.selectableActionId,
name: selectableActions.name,
isDisabled: isCardDisabled
};

if (isClickable && !isSelectable) {
return { ...baseProps, onClick: handleActionClick };
}
if (isSelectable) {
return { ...baseProps, onChange: selectableActions.onChange, isChecked: selectableActions.isChecked };
}

return baseProps;
};

return (
<div
className={css(styles.cardHeader, isToggleRightAligned && styles.modifiers.toggleRight, className)}
id={id}
{...props}
>
{onExpand && !isToggleRightAligned && cardHeaderToggle}
{actions && <CardActions className={actions?.className} hasNoOffset={actions?.hasNoOffset}> {actions.actions} </CardActions>}
{(actions || (selectableActions && (isClickable || isSelectable))) && (
<CardActions
className={actions?.className}
hasNoOffset={actions?.hasNoOffset || selectableActions?.hasNoOffset}
>
{actions?.actions}
{selectableActions && (isClickable || isSelectable) && (
<CardSelectableActions className={selectableActions?.className}>
{selectableActions?.variant === 'single' || (isClickable && !isSelectable) ? (
<Radio {...getClickableSelectableProps()} />
) : (
<Checkbox {...getClickableSelectableProps()} />
)}
</CardSelectableActions>
)}
</CardActions>
)}
{children && <CardHeaderMain>{children}</CardHeaderMain>}
{onExpand && isToggleRightAligned && cardHeaderToggle}
</div>
Expand Down
21 changes: 21 additions & 0 deletions packages/react-core/src/components/Card/CardSelectableActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';
import { css } from '@patternfly/react-styles';
import styles from '@patternfly/react-styles/css/components/Card/card';

export interface CardActionsProps extends React.HTMLProps<HTMLDivElement> {
/** Content rendered inside the card action */
children?: React.ReactNode;
/** Additional classes added to the action */
className?: string;
}

export const CardSelectableActions: React.FunctionComponent<CardActionsProps> = ({
children,
className,
...props
}: CardActionsProps) => (
<div className={css(styles.cardSelectableActions, className)} {...props}>
{children}
</div>
);
CardSelectableActions.displayName = 'CardSelectableActions';
18 changes: 11 additions & 7 deletions packages/react-core/src/components/Card/__tests__/Card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('Card', () => {
test('allows passing in a React Component as the component', () => {
const Component = () => <div>im a div</div>;

render(<Card component={(Component as unknown) as keyof JSX.IntrinsicElements} />);
render(<Card component={Component as unknown as keyof JSX.IntrinsicElements} />);
expect(screen.getByText('im a div')).toBeInTheDocument();
});

Expand All @@ -47,16 +47,18 @@ describe('Card', () => {

const card = screen.getByText('selectable card');
expect(card).toHaveClass('pf-m-selectable');
expect(card).toHaveAttribute('tabindex', '0');
});

test('card with isSelectable and isSelected applied ', () => {
render(<Card isSelectable isSelected>selected and selectable card</Card>);
render(
<Card isSelectable isSelected>
selected and selectable card
</Card>
);

const card = screen.getByText('selected and selectable card');
expect(card).toHaveClass('pf-m-selectable');
expect(card).toHaveClass('pf-m-selected');
expect(card).toHaveAttribute('tabindex', '0');
});

test('card with only isSelected applied - not change', () => {
Expand All @@ -81,7 +83,11 @@ describe('Card', () => {
});

test('card with isSelectableRaised and isSelected applied ', () => {
render(<Card isSelected isSelectableRaised>raised selected card</Card>);
render(
<Card isSelected isSelectableRaised>
raised selected card
</Card>
);

const card = screen.getByText('raised selected card');
expect(card).toHaveClass('pf-m-selectable-raised');
Expand Down Expand Up @@ -154,7 +160,6 @@ describe('Card', () => {
});

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);
Expand All @@ -179,7 +184,6 @@ describe('Card', () => {
});

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ exports[`CardHeader actions are rendered 1`] = `
>
<div
class="pf-v5-c-card__actions"
>
</div>
/>
</div>
</DocumentFragment>
`;
Expand Down

0 comments on commit c3cf823

Please sign in to comment.