diff --git a/packages/grafana-ui/src/components/Card/Card.mdx b/packages/grafana-ui/src/components/Card/Card.mdx index e3364418c5a..a5efbed8383 100644 --- a/packages/grafana-ui/src/components/Card/Card.mdx +++ b/packages/grafana-ui/src/components/Card/Card.mdx @@ -384,6 +384,28 @@ Card can have a disabled state, effectively making it and its actions non-clicka +### Selectable + +```jsx + + Option #1 + This is a really great option, you won't regret it. + + Grafana Logo + + +``` + + + + Option #1 + This is a really great option, you won't regret it. + + Grafana Logo + + + + ### Props diff --git a/packages/grafana-ui/src/components/Card/Card.story.tsx b/packages/grafana-ui/src/components/Card/Card.story.tsx index 6c88efd080c..08893cf8d50 100644 --- a/packages/grafana-ui/src/components/Card/Card.story.tsx +++ b/packages/grafana-ui/src/components/Card/Card.story.tsx @@ -154,3 +154,27 @@ export const Full: Story = ({ disabled }) => { ); }; + +export const Selected: Story = () => { + return ( + + Spaces + Spaces are the superior form of indenting code. + + Grafana Logo + + + ); +}; + +export const NotSelected: Story = () => { + return ( + + Tabs + Tabs are the preferred way of indentation. + + Grafana Logo + + + ); +}; diff --git a/packages/grafana-ui/src/components/Card/Card.test.tsx b/packages/grafana-ui/src/components/Card/Card.test.tsx index 6dd2b587348..9b147a42b01 100644 --- a/packages/grafana-ui/src/components/Card/Card.test.tsx +++ b/packages/grafana-ui/src/components/Card/Card.test.tsx @@ -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( + + My Option + + ); + + expect(screen.getByRole('radio')).toBeInTheDocument(); + expect(screen.getByRole('radio')).toBeChecked(); + + rerender( + + My Option + + ); + + expect(screen.getByRole('radio')).toBeInTheDocument(); + expect(screen.getByRole('radio')).not.toBeChecked(); + + rerender( + + My Option + + ); + + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); + }); }); }); diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx index 92466b9315e..2ba11fc31f5 100644 --- a/packages/grafana-ui/src/components/Card/Card.tsx +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -19,6 +19,7 @@ export interface Props extends Omit { @@ -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 ( - + {!hasHeadingComponent && } {deprecatedHeading && {deprecatedHeading}} {deprecatedDescription && {deprecatedDescription}} @@ -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 (

@@ -111,6 +115,7 @@ const Heading = ({ children, className, 'aria-label': ariaLabel }: ChildProps & ) : ( <>{children} )} + {isSelected !== undefined && }

); }; diff --git a/packages/grafana-ui/src/components/Card/CardContainer.tsx b/packages/grafana-ui/src/components/Card/CardContainer.tsx index 8cfa2e2ae2b..64bfe047e9c 100644 --- a/packages/grafana-ui/src/components/Card/CardContainer.tsx +++ b/packages/grafana-ui/src/components/Card/CardContainer.tsx @@ -39,6 +39,8 @@ export interface CardContainerProps extends HTMLAttributes, 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 (
{children} @@ -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), + }), + }), + }; + } +);