grafana/ui: Selectable Card (#45538)

This commit is contained in:
Gilles De Mey 2022-02-28 11:23:23 +01:00 committed by GitHub
parent 42e516523f
commit 15d681b823
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 146 additions and 52 deletions

View File

@ -384,6 +384,28 @@ Card can have a disabled state, effectively making it and its actions non-clicka
</Card>
</Preview>
### Selectable
```jsx
<Card isSelected disabled>
<Card.Heading>Option #1</Card.Heading>
<Card.Meta>This is a really great option, you won't regret it.</Card.Meta>
<Card.Figure>
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
</Card>
```
<Preview>
<Card isSelected disabled>
<Card.Heading>Option #1</Card.Heading>
<Card.Description>This is a really great option, you won't regret it.</Card.Description>
<Card.Figure>
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
</Card>
</Preview>
### Props
<Props of={Card} />

View File

@ -154,3 +154,27 @@ export const Full: Story<Props> = ({ disabled }) => {
</Card>
);
};
export const Selected: Story<Props> = () => {
return (
<Card isSelected>
<Card.Heading>Spaces</Card.Heading>
<Card.Description>Spaces are the superior form of indenting code.</Card.Description>
<Card.Figure>
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
</Card>
);
};
export const NotSelected: Story<Props> = () => {
return (
<Card isSelected={false}>
<Card.Heading>Tabs</Card.Heading>
<Card.Description>Tabs are the preferred way of indentation.</Card.Description>
<Card.Figure>
<img src={logo} alt="Grafana Logo" width="40" height="40" />
</Card.Figure>
</Card>
);
};

View File

@ -99,5 +99,33 @@ describe('Card', () => {
expect(screen.getByRole('button', { name: 'Click Me' })).not.toBeDisabled();
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
});
it('Should allow selectable cards', () => {
const { rerender } = render(
<Card isSelected={true}>
<Card.Heading>My Option</Card.Heading>
</Card>
);
expect(screen.getByRole('radio')).toBeInTheDocument();
expect(screen.getByRole('radio')).toBeChecked();
rerender(
<Card isSelected={false}>
<Card.Heading>My Option</Card.Heading>
</Card>
);
expect(screen.getByRole('radio')).toBeInTheDocument();
expect(screen.getByRole('radio')).not.toBeChecked();
rerender(
<Card>
<Card.Heading>My Option</Card.Heading>
</Card>
);
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
});
});
});

View File

@ -19,6 +19,7 @@ export interface Props extends Omit<CardContainerProps, 'disableEvents' | 'disab
heading?: ReactNode;
/** @deprecated Use `Card.Description` instead */
description?: string;
isSelected?: boolean;
}
export interface CardInterface extends FC<Props> {
@ -35,6 +36,7 @@ const CardContext = React.createContext<{
href?: string;
onClick?: () => void;
disabled?: boolean;
isSelected?: boolean;
} | null>(null);
/**
@ -49,6 +51,7 @@ export const Card: CardInterface = ({
children,
heading: deprecatedHeading,
description: deprecatedDescription,
isSelected,
className,
...htmlProps
}) => {
@ -63,16 +66,17 @@ export const Card: CardInterface = ({
const disableHover = disabled || (!onClick && !href);
const onCardClick = onClick && !disabled ? onClick : undefined;
const theme = useTheme2();
const styles = getCardContainerStyles(theme, disabled, disableHover);
const styles = getCardContainerStyles(theme, disabled, disableHover, isSelected);
return (
<CardContainer
disableEvents={disabled}
disableHover={disableHover}
isSelected={isSelected}
className={cx(styles.container, className)}
{...htmlProps}
>
<CardContext.Provider value={{ href, onClick: onCardClick, disabled }}>
<CardContext.Provider value={{ href, onClick: onCardClick, disabled, isSelected }}>
{!hasHeadingComponent && <Heading />}
{deprecatedHeading && <Heading>{deprecatedHeading}</Heading>}
{deprecatedDescription && <Description>{deprecatedDescription}</Description>}
@ -96,7 +100,7 @@ const Heading = ({ children, className, 'aria-label': ariaLabel }: ChildProps &
const context = useContext(CardContext);
const styles = useStyles2(getHeadingStyles);
const { href, onClick } = context ?? { href: undefined, onClick: undefined };
const { href, onClick, isSelected } = context ?? { href: undefined, onClick: undefined, isSelected: undefined };
return (
<h2 className={cx(styles.heading, className)}>
@ -111,6 +115,7 @@ const Heading = ({ children, className, 'aria-label': ariaLabel }: ChildProps &
) : (
<>{children}</>
)}
{isSelected !== undefined && <input aria-label="option" type="radio" checked={isSelected} />}
</h2>
);
};

View File

@ -39,6 +39,8 @@ export interface CardContainerProps extends HTMLAttributes<HTMLOrSVGElement>, Ca
disableEvents?: boolean;
/** No style change on hover */
disableHover?: boolean;
/** Makes the card selectable, set to "true" to apply selected styles */
isSelected?: boolean;
/** Custom container styles */
className?: string;
}
@ -48,12 +50,13 @@ export const CardContainer = ({
children,
disableEvents,
disableHover,
isSelected,
className,
href,
...props
}: CardContainerProps) => {
const theme = useTheme2();
const { oldContainer } = getCardContainerStyles(theme, disableEvents, disableHover);
const { oldContainer } = getCardContainerStyles(theme, disableEvents, disableHover, isSelected);
return (
<div {...props} className={cx(oldContainer, className)}>
<CardInner href={href}>{children}</CardInner>
@ -61,59 +64,71 @@ export const CardContainer = ({
);
};
export const getCardContainerStyles = stylesFactory((theme: GrafanaTheme2, disabled = false, disableHover = false) => {
return {
container: css({
display: 'grid',
position: 'relative',
gridTemplateColumns: 'auto 1fr auto',
gridTemplateRows: '1fr auto auto auto',
gridAutoColumns: '1fr',
gridAutoFlow: 'row',
gridTemplateAreas: `
export const getCardContainerStyles = stylesFactory(
(theme: GrafanaTheme2, disabled = false, disableHover = false, isSelected = false) => {
const isSelectable = isSelected !== undefined;
return {
container: css({
display: 'grid',
position: 'relative',
gridTemplateColumns: 'auto 1fr auto',
gridTemplateRows: '1fr auto auto auto',
gridAutoColumns: '1fr',
gridAutoFlow: 'row',
gridTemplateAreas: `
"Figure Heading Tags"
"Figure Meta Tags"
"Figure Description Tags"
"Figure Actions Secondary"`,
width: '100%',
padding: theme.spacing(2),
background: theme.colors.background.secondary,
borderRadius: theme.shape.borderRadius(),
marginBottom: '8px',
pointerEvents: disabled ? 'none' : 'auto',
transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
duration: theme.transitions.duration.short,
}),
width: '100%',
padding: theme.spacing(2),
background: theme.colors.background.secondary,
borderRadius: theme.shape.borderRadius(),
marginBottom: '8px',
pointerEvents: disabled ? 'none' : 'auto',
transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
duration: theme.transitions.duration.short,
}),
...(!disableHover && {
'&:hover': {
background: theme.colors.emphasize(theme.colors.background.secondary, 0.03),
cursor: 'pointer',
zIndex: 1,
},
'&:focus': styleMixins.getFocusStyles(theme),
}),
}),
oldContainer: css({
display: 'flex',
width: '100%',
background: theme.colors.background.secondary,
borderRadius: theme.shape.borderRadius(),
position: 'relative',
pointerEvents: disabled ? 'none' : 'auto',
marginBottom: theme.spacing(1),
transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
duration: theme.transitions.duration.short,
}),
...(!disableHover && {
'&:hover': {
background: theme.colors.emphasize(theme.colors.background.secondary, 0.03),
cursor: 'pointer',
zIndex: 1,
},
'&:focus': styleMixins.getFocusStyles(theme),
}),
...(!disableHover && {
'&:hover': {
background: theme.colors.emphasize(theme.colors.background.secondary, 0.03),
...(isSelectable && {
cursor: 'pointer',
zIndex: 1,
},
'&:focus': styleMixins.getFocusStyles(theme),
}),
...(isSelected && {
outline: `solid 2px ${theme.colors.primary.border}`,
}),
}),
}),
};
});
oldContainer: css({
display: 'flex',
width: '100%',
background: theme.colors.background.secondary,
borderRadius: theme.shape.borderRadius(),
position: 'relative',
pointerEvents: disabled ? 'none' : 'auto',
marginBottom: theme.spacing(1),
transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
duration: theme.transitions.duration.short,
}),
...(!disableHover && {
'&:hover': {
background: theme.colors.emphasize(theme.colors.background.secondary, 0.03),
cursor: 'pointer',
zIndex: 1,
},
'&:focus': styleMixins.getFocusStyles(theme),
}),
}),
};
}
);