import type { AvatarProps } from '@planview/pv-uikit' import { Avatar, Badge, Tooltip } from '@planview/pv-uikit' import { align, theme, iconSizes, overflow, sizePx, spacingPx, text, useIsFocusVisible, cursor, useLinkClick, } from '@planview/pv-utilities' import type { CSSProperties } from 'react' import React from 'react' import styled, { css } from 'styled-components' import type { IconProps } from '@planview/pv-icons' import { CaretDown } from '@planview/pv-icons' const ShrinkableBadge = styled(Badge)` flex-shrink: 1; ` const CaretWrapper = styled.div<{ $hasIcon?: boolean }>` display: none; box-sizing: border-box; position: absolute; inset: 0 0 0 auto; place-content: center; width: ${sizePx.small}; overflow: visible; ${(props) => props.$hasIcon ? css` background-color: var( --grid-cell-bg, var( --grid-row-background-color, ${theme.backgroundNeutral0} ) ); ` : css` &::before { z-index: 1; content: ''; position: absolute; inset: 0 0 0 auto; display: block; width: ${sizePx.medium}; height: 100%; background-color: var( --grid-cell-bg, var( --grid-row-background-color, ${theme.backgroundNeutral0} ) ); mask-image: linear-gradient( 90deg, rgb(0 0 0 / 0%) 0%, rgb(0 0 0 / 100%) 40%, rgb(0 0 0 / 100%) 100%, transparent ); } `} ` const StyledCaretDown = styled(CaretDown)` z-index: 2; ${cursor.pointer}; ` const CCell = styled.div<{ $paddingLeft: boolean $focusVisible: boolean $paddingRight: boolean $aggregated: boolean $numeric: boolean }>` ${(props) => props.$aggregated ? props.$numeric ? text.numericSemibold : text.semibold : props.$numeric ? text.numeric : text.regular}; min-height: ${sizePx.small}; height: 100%; width: 100%; padding: 0 ${(props) => (props.$paddingRight ? spacingPx.small : 0)} 0 ${(props) => (props.$paddingLeft ? spacingPx.small : 0)}; display: flex; flex-direction: row; justify-content: space-between; align-items: center; min-width: 0; position: relative; &:focus, *:focus { outline: none; } &:hover ${CaretWrapper} { display: grid; } ${(props) => props.$focusVisible ? `${CaretWrapper} { display: grid; }` : ''} ` const anchorStyles = css` ${cursor.pointer}; color: ${theme.textLinkNormal}; text-decoration: none; &:hover { text-decoration: underline; text-decoration-color: ${theme.textLinkHover}; color: ${theme.textLinkHover}; } &:focus { outline: none; text-decoration: underline; text-decoration-color: ${theme.textLinkHover}; color: ${theme.textLinkHover}; } ` const Ellipsed = styled.div<{ $asLink?: boolean; $align: 'left' | 'right' }>` ${overflow.ellipsis}; color: ${theme.textPrimary}; ${(props) => (props.$asLink ? anchorStyles : '')}; ${(props) => props.$align === 'right' && css` text-align: right; flex-grow: 1; justify-content: flex-end; `} ` const LeftWrap = styled.div<{ $hasBadge: boolean; $align: 'left' | 'right' }>` ${align.centerV}; min-height: ${sizePx.small}; min-width: 0; gap: ${(props) => (props.$hasBadge ? spacingPx.small : 0)}; ${(props) => props.$align === 'right' && css` display: flex; flex-grow: 1; `} ` type GridCellDefaultIcon = { /** * Icon component */ icon?: React.ReactElement /** * URL to avatar image or Avatar component */ avatar?: never /** * Badge to display */ badge?: never } type GridCellDefaultAvatar = { /** * Icon component */ icon?: never /** * URL to avatar image or Avatar component */ avatar?: string | React.ReactElement /** * Badge to display */ badge?: never } type GridCellDefaultBadge = { /** * Icon component */ icon?: never /** * URL to avatar image or Avatar component */ avatar?: never /** * Badge to display */ badge?: string | number } type AnchorTagConfig = Pick< React.ComponentPropsWithoutRef<'a'>, 'download' | 'rel' | 'target' | 'onClick' > & { url: string preventNavigation?: boolean } export type GridCellDefaultProps = ( | GridCellDefaultIcon | GridCellDefaultAvatar | GridCellDefaultBadge ) & { /** * Accessible tab index. You should generally pass through the tabIndex * provided to the Renderer component. */ tabIndex: number /** * Display value */ label?: string /** * String to pass to a tooltip around icon/avatar/badge. Note, if `align` is set * to `right` this content will appear on the right. */ leftContentTooltip?: string /** * Background color to use. Should only use official colors in * the 0-200 range to be properly accessible with textPrimary unless * also setting a color to ensure accessible contrast. */ backgroundColor?: string /** * Color to use for the text. Should use official text colors or custom * colors that have enough contrast against backgroundColor. */ color?: string /** Renders a caret icon on the right on hover or focus */ withCaret?: boolean /** Renders the value in bold to indicate aggregation */ aggregated?: boolean /** * Using this prop will render the label as an anchor tag. Other elements * such as icon, avatar, and badge will be rendered outside the anchor tag. * * Accepts a `string` or a * ``` type AnchorTagConfig = { url: string rel?: string target?: string download?: string preventNavigation?: boolean onClick?: React.MouseEventHandler } ``` * When using the `AnchorTagConfig` the url (as href), rel, target, download and onClick will be passed to the anchor element * */ href?: string | AnchorTagConfig /** * @deprecated Pass onClick to the `href` prop instead * @see {@link GridCellDefaultProps.href} * * Callback when link is clicked */ onLinkClick?: React.MouseEventHandler /** Should the field use tabular (monospace) numbers? Defaults `align` to `right` if not overridden. */ numeric?: boolean /** Should the content of the cell be right or left aligned. Defaults to `left` unless `numeric` is true. */ align?: 'left' | 'right' } const GridCellDefaultImpl = ({ label, tabIndex = 0, avatar, aggregated, icon, badge, leftContentTooltip = '', backgroundColor, color, withCaret, href, onLinkClick, numeric, align = numeric ? 'right' : 'left', }: GridCellDefaultProps) => { const badgeRef = React.useRef(null) const tabIndexForIconContent = label ? undefined : tabIndex const { innerRef: _innerRef, focusVisible, ...focusProps } = useIsFocusVisible() const hrefProps = React.useMemo(() => { if (typeof href === 'string') { return { href } } else if (href) { return { href: href.url, rel: href.rel, target: href.target, download: href.download, } } return {} }, [href]) const badgeRefHandler = React.useCallback((ref: HTMLDivElement | null) => { /* We'd need a special ref to get access to the ellipsed content otherwise */ badgeRef.current = ref?.querySelector('span') || null }, []) const iconContent = React.useMemo(() => { const c = icon ? ( React.cloneElement(icon, { size: iconSizes.SIZE_SMALL, tabIndex: tabIndexForIconContent, color: icon.props.color || theme.iconNormal, }) ) : avatar ? ( //Need to wrap this avatar in a focusable element since tooltip will not work if avatar receives tabIndex {typeof avatar === 'string' ? ( ) : ( React.cloneElement(avatar, { size: 'small', }) )} ) : badge != null ? ( {badge} ) : null if (c) { return ( {c} ) } return null }, [ avatar, icon, badge, badgeRefHandler, leftContentTooltip, tabIndexForIconContent, ]) const hasBadge = !!badge && !icon && !avatar const handleClick = React.useCallback( (e: React.MouseEvent) => { e.stopPropagation() if (typeof href === 'object' && href.onClick) { href.onClick(e) } else { /* Fallback for deprecated onLinkClick method */ onLinkClick?.(e) } }, [onLinkClick, href] ) const handleLinkClick = useLinkClick( handleClick, typeof href === 'object' && href.preventNavigation ) return ( {align === 'left' && iconContent} {label ? ( {label} ) : null} {align === 'right' && iconContent} {withCaret ? ( ) : null} ) } /** * Implementation of [grid renderer default](https://design.planview.com/components/grid/grid-renderer-default) * which is used by the default to render all cells. * * Though a lot is supported by this component, the default cell renderer only * leverages one aspect: rendering content that is is ellipsed when it would * overflow the cell (and provides a tooltip when this happens). * * To fully leverage this component, use a cell Renderer function that returns this * component. * * ```tsx * const calendarColumn: Column = { * id: "date", * cell: { * Renderer: ({ label, tabIndex }) => ( * } * leftContentTooltip="Tooltip for calendar icon" * tabIndex={tabIndex} /> * ) * } * } * ``` */ export const GridCellDefault = React.memo( GridCellDefaultImpl ) as typeof GridCellDefaultImpl