/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. * * Modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import React, { FunctionComponent, ReactElement, ReactNode, isValidElement, HTMLAttributes, } from 'react'; import classNames from 'classnames'; import { CommonProps, ExclusiveUnion, keysOf } from '../common'; import { getSecureRelForTarget } from '../../services'; import { OuiText } from '../text'; import { OuiTitle } from '../title'; import { OuiBetaBadge, OuiBetaBadgeProps } from '../badge/beta_badge'; import { OuiIconProps } from '../icon'; import { OuiCardSelect, OuiCardSelectProps, ouiCardSelectableColor, } from './card_select'; import { htmlIdGenerator } from '../../services/accessibility'; import { validateHref } from '../../services/security/href_validator'; import { OuiPanel, OuiPanelProps } from '../panel'; type CardAlignment = 'left' | 'center' | 'right'; const textAlignToClassNameMap: { [alignment in CardAlignment]: string } = { left: 'ouiCard--leftAligned', center: 'ouiCard--centerAligned', right: 'ouiCard--rightAligned', }; export const ALIGNMENTS = keysOf(textAlignToClassNameMap); type CardLayout = 'vertical' | 'horizontal'; const layoutToClassNameMap: { [layout in CardLayout]: string } = { vertical: '', horizontal: 'ouiCard--horizontal', }; export const LAYOUT_ALIGNMENTS = keysOf(layoutToClassNameMap); /** * Certain props are only allowed when the layout is vertical */ type OuiCardPropsLayout = ExclusiveUnion< { layout?: 'vertical'; /** * Changes alignment of the title and description */ textAlign?: CardAlignment; /** * Accepts any combination of elements */ footer?: ReactNode; /** * Accepts a url in string form or ReactElement for a custom image component */ image?: string | ReactElement; }, { /** * Change to "horizontal" if you need the icon to be left of the content. * Horizontal layouts cannot be used in conjunction with `image`, `footer`, or `textAlign`. */ layout: 'horizontal'; } >; export type OuiCardProps = Omit & Omit, 'color' | 'title' | 'onClick'> & OuiCardPropsLayout & { /** * Cards are required to have at least a title and a description and/or children */ title: NonNullable; /** * Determines the title's heading element */ titleElement?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span'; /** * Determines the title's size, matching that of OuiTitle. * Though, card titles can't be too large or small relative to the description text. */ titleSize?: 's' | 'xs'; /** * Placed within a small OuiText `

` tag */ description?: NonNullable; /** * Accepts an `` node or `null` */ icon?: ReactElement | null; /** * Custom children */ children?: ReactNode; /** * Use only if you want to forego a button in the footer and make the whole card clickable */ onClick?: | React.MouseEventHandler | React.MouseEventHandler; isDisabled?: boolean; href?: string; target?: string; rel?: string; /** * Add a badge to the card to label it as "Beta" or other non-GA state * **DEPRECATED: Use `betaBadgeProps.label` instead.** */ betaBadgeLabel?: string; /** * Add a description to the beta badge (will appear in a tooltip) * **DEPRECATED: Use `betaBadgeProps.tooltipContent` instead.** */ betaBadgeTooltipContent?: ReactNode; /** * Optional title will be supplied as tooltip title or title attribute otherwise the label will be used. * **DEPRECATED: Use `betaBadgeProps.title` instead.** */ betaBadgeTitle?: string; betaBadgeProps?: Partial; /** * Matches to the color property of OuiPanel. If defined, removes any border & shadow. * Leave as `undefined` to display as a default panel. * Selectable cards will always display as a default panel. */ display?: OuiPanelProps['color']; /** * Padding applied around the content of the card */ paddingSize?: OuiPanelProps['paddingSize']; /** * Adds a button to the bottom of the card to allow for in-place selection */ selectable?: OuiCardSelectProps; /** * Use a border style of card instead of shadow */ hasBorder?: OuiPanelProps['hasBorder']; } & ( | { // description becomes optional when children is present description?: NonNullable; children: ReactNode; } | { // description is required if children is omitted description: NonNullable; } ); export const OuiCard: FunctionComponent = ({ className, description, isDisabled: _isDisabled, title, titleElement = 'span', titleSize = 's', icon, image, children, footer, onClick, href, rel, target, textAlign = 'center', betaBadgeLabel, betaBadgeTooltipContent, betaBadgeTitle, betaBadgeProps, layout = 'vertical', selectable, display, paddingSize, ...rest }) => { const isHrefValid = !href || validateHref(href); const isDisabled = _isDisabled || !isHrefValid; const isClickable = !isDisabled && (onClick || href || (selectable && !selectable.isDisabled)); /** * For a11y, we simulate the same click that's provided on the title when clicking the whole card * without having to make the whole card a button or anchor tag. * *Card Accessibility: The redundant click event https://inclusive-components.design/cards/* */ let link: HTMLAnchorElement | HTMLButtonElement | null = null; const outerOnClick = (e: React.MouseEvent) => { if (link && link !== e.target) { link.click(); } }; if (layout === 'horizontal') { if (image || footer || textAlign !== 'center') { throw new Error( 'OuiCard: `layout="horizontal"` cannot be used in conjunction with `image`, `footer`, or `textAlign`.' ); } } const selectableColorClass = selectable ? `ouiCard--isSelectable--${ouiCardSelectableColor( selectable.color, selectable.isSelected )}` : undefined; const classes = classNames( 'ouiCard', textAlignToClassNameMap[textAlign], layoutToClassNameMap[layout], { 'ouiCard--isClickable': isClickable, 'ouiCard--hasBetaBadge': betaBadgeLabel, 'ouiCard--hasIcon': icon, 'ouiCard--isSelectable': selectable, 'ouiCard-isSelected': selectable && selectable.isSelected, 'ouiCard-isDisabled': isDisabled, }, selectableColorClass, className ); const ariaId = htmlIdGenerator()(); const ariaDesc = description ? `${ariaId}Description` : ''; /** * Top area containing image, icon or both */ let imageNode; if (image && layout === 'vertical') { if (isValidElement(image) || typeof image === 'string') { imageNode = (

{isValidElement(image) ? image : }
); } else { imageNode = null; } } let iconNode; if (icon) { iconNode = React.cloneElement(icon, { className: classNames(icon.props.className, 'ouiCard__icon'), }); } let optionalCardTop; if (imageNode || iconNode) { optionalCardTop = (
{imageNode} {iconNode}
); } /** * Optional OuiBetaBadge */ let optionalBetaBadge; let optionalBetaBadgeID = ''; if (betaBadgeLabel) { optionalBetaBadgeID = `${ariaId}BetaBadge`; optionalBetaBadge = ( ); // Increase padding size when there is a beta badge unless it's already determined paddingSize = paddingSize || 'l'; } /** * Optional selectable button */ if (selectable && isDisabled && selectable.isDisabled === undefined) { selectable.isDisabled = isDisabled; } let optionalSelectButton; if (selectable) { optionalSelectButton = ( { link = node; }} /> ); } /** * Wraps the title with the link () or button. * This makes the title element a11y friendly and gets described by its content if its interactable. */ let theTitle; if (!isDisabled && href) { theTitle = ( } href={href} target={target} aria-describedby={ariaDesc} rel={getSecureRelForTarget({ href, target, rel })} ref={(node) => { link = node; }}> {title} ); } else if (isDisabled || onClick) { theTitle = ( ); } else { theTitle = title; } /** * Convert titleElement to a capital TitleElement */ const TitleElement = titleElement; return ( {optionalCardTop}
{theTitle} {description && (

{description}

)} {children &&
{children}
}
{/* Beta badge should always be after the title/description but before any footer buttons */} {optionalBetaBadge} {layout === 'vertical' && footer && (
{footer}
)} {optionalSelectButton}
); };