diff --git a/docs/data/material/components/cards/ActionableCard.js b/docs/data/material/components/cards/ActionableCard.js new file mode 100644 index 00000000000000..4219c599f818ff --- /dev/null +++ b/docs/data/material/components/cards/ActionableCard.js @@ -0,0 +1,69 @@ +import * as React from 'react'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardMedia from '@mui/material/CardMedia'; +import Typography from '@mui/material/Typography'; +import CardHeader from '@mui/material/CardHeader'; +import CardActions from '@mui/material/CardActions'; +import IconButton from '@mui/material/IconButton'; +import FavoriteIcon from '@mui/icons-material/Favorite'; +import ShareIcon from '@mui/icons-material/Share'; + +export default function ActionableCard() { + return ( + + + + + + + Lizards are a widespread group of squamate reptiles, with over 6,000 + species, ranging across all continents except Antarctica + + + + alert('Favorite clicked')} + > + + + alert('Share clicked')}> + + + + + alert('Card clicked')} sx={{ maxWidth: 345 }}> + + + + + Lizards are a widespread group of squamate reptiles, with over 6,000 + species, ranging across all continents except Antarctica + + + + alert('Favorite clicked')} + > + + + alert('Share clicked')}> + + + + + + ); +} diff --git a/docs/data/material/components/cards/ActionableCard.tsx b/docs/data/material/components/cards/ActionableCard.tsx new file mode 100644 index 00000000000000..4219c599f818ff --- /dev/null +++ b/docs/data/material/components/cards/ActionableCard.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardMedia from '@mui/material/CardMedia'; +import Typography from '@mui/material/Typography'; +import CardHeader from '@mui/material/CardHeader'; +import CardActions from '@mui/material/CardActions'; +import IconButton from '@mui/material/IconButton'; +import FavoriteIcon from '@mui/icons-material/Favorite'; +import ShareIcon from '@mui/icons-material/Share'; + +export default function ActionableCard() { + return ( + + + + + + + Lizards are a widespread group of squamate reptiles, with over 6,000 + species, ranging across all continents except Antarctica + + + + alert('Favorite clicked')} + > + + + alert('Share clicked')}> + + + + + alert('Card clicked')} sx={{ maxWidth: 345 }}> + + + + + Lizards are a widespread group of squamate reptiles, with over 6,000 + species, ranging across all continents except Antarctica + + + + alert('Favorite clicked')} + > + + + alert('Share clicked')}> + + + + + + ); +} diff --git a/docs/data/material/components/cards/cards.md b/docs/data/material/components/cards/cards.md index 2eddaaf0688aa2..957d2cdac053f2 100644 --- a/docs/data/material/components/cards/cards.md +++ b/docs/data/material/components/cards/cards.md @@ -70,6 +70,18 @@ A card can also offer supplemental actions which should stand detached from the {{"demo": "MultiActionAreaCard.js", "bg": true}} +## Actionable card with link + +A card's primary action is related to its main subject: the heading. The primary action is to expand the subject in the heading, either by navigating through a link, or executing an action. For this reason, Card accepts `href` and `onClick`, which render the CardHead heading inside a link / button element. This element is focusable, and its focus style is reflected on the whole card. The card can also have other focusable elements in CardHeader, CardContent and CardActions. By default, CardActions, in this case, will render a visual indicating "Read more" element, but that could be customized or removed via slots. + +Accessibility: + +- all focusable elements are reachable, main action is executed when clicking on the whole card. +- all focusable elements are in the tab order. +- aria-description on the button/link to "read more". + +{{"demo": "ActionableCard.js", "bg": true}} + ## UI Controls Supplemental actions within the card are explicitly called out using icons, text, and UI controls, typically placed at the bottom of the card. diff --git a/packages/mui-material/src/Card/Card.d.ts b/packages/mui-material/src/Card/Card.d.ts index acf36abfca13da..5d53497454cbce 100644 --- a/packages/mui-material/src/Card/Card.d.ts +++ b/packages/mui-material/src/Card/Card.d.ts @@ -23,6 +23,14 @@ export interface CardOwnProps extends DistributiveOmit * The system prop that allows defining system overrides as well as additional CSS styles. */ sx?: SxProps | undefined; + /** + * If provided, the card will render a clickable link. + */ + href?: string | undefined; + /** + * If provided, the card will call this function when clicked. + */ + onClick?: React.MouseEventHandler | undefined; } export interface CardTypeMap< diff --git a/packages/mui-material/src/Card/Card.js b/packages/mui-material/src/Card/Card.js index 8d43d4c0364f81..6b745f61574f14 100644 --- a/packages/mui-material/src/Card/Card.js +++ b/packages/mui-material/src/Card/Card.js @@ -8,6 +8,7 @@ import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; import Paper from '../Paper'; import { getCardUtilityClass } from './cardClasses'; +import { CardContextProvider } from './CardContext'; const useUtilityClasses = (ownerState) => { const { classes } = ownerState; @@ -22,9 +23,26 @@ const useUtilityClasses = (ownerState) => { const CardRoot = styled(Paper, { name: 'MuiCard', slot: 'Root', -})({ +})(({ ownerState, theme }) => ({ overflow: 'hidden', -}); + position: 'relative', + ...(ownerState.clickable && { + '&::after': { + content: '""', + position: 'absolute', + inset: 0, + backgroundColor: 'currentcolor', + opacity: 0, + transition: theme.transitions.create('opacity', { + duration: theme.transitions.duration.short, + }), + pointerEvents: 'none', + }, + '&:hover::after': { + opacity: (theme.vars || theme).palette.action.hoverOpacity, + }, + }), +})); const Card = React.forwardRef(function Card(inProps, ref) { const props = useDefaultProps({ @@ -32,20 +50,22 @@ const Card = React.forwardRef(function Card(inProps, ref) { name: 'MuiCard', }); - const { className, raised = false, ...other } = props; + const { className, raised = false, href, onClick, ...other } = props; - const ownerState = { ...props, raised }; + const ownerState = { ...props, raised, clickable: !!(href || onClick) }; const classes = useUtilityClasses(ownerState); return ( - + + + ); }); @@ -66,6 +86,14 @@ Card.propTypes /* remove-proptypes */ = { * @ignore */ className: PropTypes.string, + /** + * If provided, the card will render a clickable link. + */ + href: PropTypes.string, + /** + * If provided, the card will call this function when clicked. + */ + onClick: PropTypes.func, /** * If `true`, the card will use raised styling. * @default false diff --git a/packages/mui-material/src/Card/CardContext.ts b/packages/mui-material/src/Card/CardContext.ts new file mode 100644 index 00000000000000..e27345bc8d9c9c --- /dev/null +++ b/packages/mui-material/src/Card/CardContext.ts @@ -0,0 +1,22 @@ +'use client'; +import * as React from 'react'; + +type CardContextValue = { + href?: string | undefined; + onClick?: React.MouseEventHandler | undefined; +}; + +const CardContext = React.createContext(null); + +const useCardContext = () => { + const context = React.useContext(CardContext); + + if (context === null) { + throw new Error('useCardContext must be used within a Card'); + } + return context; +}; + +const CardContextProvider = CardContext.Provider; + +export { CardContextProvider, useCardContext }; diff --git a/packages/mui-material/src/CardActions/CardActions.d.ts b/packages/mui-material/src/CardActions/CardActions.d.ts index d22f05fdb72937..f3ffdc0ef2849f 100644 --- a/packages/mui-material/src/CardActions/CardActions.d.ts +++ b/packages/mui-material/src/CardActions/CardActions.d.ts @@ -3,8 +3,24 @@ import { SxProps } from '@mui/system'; import { Theme } from '../styles'; import { InternalStandardProps as StandardProps } from '../internal'; import { CardActionsClasses } from './cardActionsClasses'; +import { CreateSlotsAndSlotProps, SlotProps } from '../utils'; -export interface CardActionsProps extends StandardProps> { +export interface CardActionSlots { + /** + * The component that renders the root slot. + * @default 'div' + */ + readMore: React.ElementType; +} + +export type CardActionsSlotsAndSlotProps = CreateSlotsAndSlotProps< + CardActionSlots, + { + readMore: SlotProps<'div', CardActionsClasses, CardActionsProps>; + } +>; +export interface CardActionsProps + extends StandardProps>, CardActionsSlotsAndSlotProps { /** * The content of the component. */ diff --git a/packages/mui-material/src/CardActions/CardActions.js b/packages/mui-material/src/CardActions/CardActions.js index 5213656597c565..0e53577eab75ef 100644 --- a/packages/mui-material/src/CardActions/CardActions.js +++ b/packages/mui-material/src/CardActions/CardActions.js @@ -4,14 +4,18 @@ import PropTypes from 'prop-types'; import clsx from 'clsx'; import composeClasses from '@mui/utils/composeClasses'; import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; import { useDefaultProps } from '../DefaultPropsProvider'; import { getCardActionsUtilityClass } from './cardActionsClasses'; +import { useCardContext } from '../Card/CardContext'; +import useSlot from '../utils/useSlot'; const useUtilityClasses = (ownerState) => { const { classes, disableSpacing } = ownerState; const slots = { root: ['root', !disableSpacing && 'spacing'], + readMore: ['readMore'], }; return composeClasses(slots, getCardActionsUtilityClass, classes); @@ -29,6 +33,10 @@ const CardActionsRoot = styled('div', { display: 'flex', alignItems: 'center', padding: 8, + position: 'relative', + '& > *': { + zIndex: 2, + }, variants: [ { props: { disableSpacing: false }, @@ -41,25 +49,65 @@ const CardActionsRoot = styled('div', { ], }); +const ReadMore = styled('span')( + memoTheme(({ theme }) => ({ + ...theme.typography.button, + color: (theme.vars || theme).palette.primary.main, + textTransform: 'uppercase', + padding: '6px 8px', + borderRadius: (theme.vars || theme).shape.borderRadius, + transition: theme.transitions.create(['background-color', 'color'], { + duration: theme.transitions.duration.short, + }), + marginLeft: 'auto', + pointerEvents: 'none', + })), +); + const CardActions = React.forwardRef(function CardActions(inProps, ref) { const props = useDefaultProps({ props: inProps, name: 'MuiCardActions', }); - const { disableSpacing = false, className, ...other } = props; + const { + disableSpacing = false, + className, + children, + slots = {}, + slotProps = {}, + ...other + } = props; - const ownerState = { ...props, disableSpacing }; + const ownerState = { ...props, children, disableSpacing }; const classes = useUtilityClasses(ownerState); + const externalForwardedProps = { slots, slotProps }; + + const { href, onClick } = useCardContext(); + const clickable = !!(href || onClick); + + const [ReadMoreSlot, readMoreSlotProps] = useSlot('readMore', { + className: classes.readMore, + elementType: ReadMore, + externalForwardedProps, + ownerState, + additionalProps: { + 'aria-hidden': true, + }, + }); + return ( + > + {children} + {clickable && {'Read more'}} + ); }); @@ -85,6 +133,21 @@ CardActions.propTypes /* remove-proptypes */ = { * @default false */ disableSpacing: PropTypes.bool, + /** + * The props used for each slot inside. + * @default {} + */ + slotProps: PropTypes /* @typescript-to-proptypes-ignore */.shape({ + readMore: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + * @default {} + */ + slots: PropTypes.shape({ + readMore: PropTypes.elementType, + }), /** * The system prop that allows defining system overrides as well as additional CSS styles. */ diff --git a/packages/mui-material/src/CardContent/CardContent.js b/packages/mui-material/src/CardContent/CardContent.js index 769163a78edbad..57b728572923d1 100644 --- a/packages/mui-material/src/CardContent/CardContent.js +++ b/packages/mui-material/src/CardContent/CardContent.js @@ -21,9 +21,12 @@ const CardContentRoot = styled('div', { name: 'MuiCardContent', slot: 'Root', })({ - padding: 16, + margin: 16, '&:last-child': { - paddingBottom: 24, + marginBottom: 24, + }, + '& > *': { + zIndex: 2, }, }); diff --git a/packages/mui-material/src/CardHeader/CardHeader.js b/packages/mui-material/src/CardHeader/CardHeader.js index 9d3690cfcd5aa8..67b3274817c1bb 100644 --- a/packages/mui-material/src/CardHeader/CardHeader.js +++ b/packages/mui-material/src/CardHeader/CardHeader.js @@ -7,6 +7,9 @@ import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; import cardHeaderClasses, { getCardHeaderUtilityClass } from './cardHeaderClasses'; import useSlot from '../utils/useSlot'; +import { useCardContext } from '../Card/CardContext'; +import Link from '../Link'; +import ButtonBase from '../ButtonBase'; const useUtilityClasses = (ownerState) => { const { classes } = ownerState; @@ -36,7 +39,10 @@ const CardHeaderRoot = styled('div', { })({ display: 'flex', alignItems: 'center', - padding: 16, + margin: 16, + '& > *': { + zIndex: 2, + }, }); const CardHeaderAvatar = styled('div', { @@ -93,6 +99,8 @@ const CardHeader = React.forwardRef(function CardHeader(inProps, ref) { disableTypography, }; + const { href, onClick } = useCardContext(); + const classes = useUtilityClasses(ownerState); const externalForwardedProps = { @@ -111,8 +119,77 @@ const CardHeader = React.forwardRef(function CardHeader(inProps, ref) { component: 'span', }, }); + const [LinkSlot, linkSlotProps] = useSlot('link', { + elementType: Link, + externalForwardedProps, + ownerState, + additionalProps: { + // could also point to the read more button with aria-describedby, but that's not guaranteed to be present. + 'aria-description': 'read more', + color: 'textPrimary', + href, + // underline: 'none', this does not seem to work. + sx: { + textDecoration: 'none', + outline: 'none', + '&:focus-visible': { + '&::before': { + outline: 'auto', + }, + }, + '&::before': { + content: '""', + position: 'absolute', + left: 0, + top: 0, + width: '100%', + height: '100%', + zIndex: 1, + }, + }, + }, + }); + const [ButtonSlot, buttonSlotProps] = useSlot('button', { + elementType: ButtonBase, + externalForwardedProps, + ownerState, + additionalProps: { + // could also point to the read more button with aria-describedby, but that's not guaranteed to be present. + 'aria-description': 'read more', + color: 'textPrimary', + onClick, + sx: { + font: 'inherit', + position: 'static', + '&:focus-visible': { + outline: 'none', + '&::before': { + outline: 'auto', + }, + }, + '&::before': { + content: '""', + position: 'absolute', + left: 0, + top: 0, + width: '100%', + height: '100%', + zIndex: 1, + }, + }, + }, + }); + if (title != null && title.type !== Typography && !disableTypography) { - title = {title}; + let titleContent = title; + + if (href) { + titleContent = {title}; + } else if (onClick) { + titleContent = {title}; + } + + title = {titleContent}; } let subheader = subheaderProp;