Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Card) implement new selectable/clickable card design #9092

Merged
merged 13 commits into from
May 18, 2023
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;
thatblindgeye marked this conversation as resolved.
Show resolved Hide resolved
/** 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