Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add header option to cards #671

Merged
merged 7 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 114 additions & 49 deletions src/components/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import chroma from 'chroma-js'
import { Div, type DivProps } from 'honorable'
import { forwardRef } from 'react'
import styled, { type DefaultTheme } from 'styled-components'
import { memoize } from 'lodash-es'
import { type ComponentProps, type ReactNode, forwardRef } from 'react'
import styled, { type DefaultTheme } from 'styled-components'

import { type Severity, type SeverityExt, sanitizeSeverity } from '../types'

import {
type FillLevel,
FillLevelProvider,
isFillLevel,
toFillLevel,
useFillLevel,
} from './contexts/FillLevelContext'

const HUES = ['default', 'lighter', 'lightest'] as const
import WrapWithIf from './WrapWithIf'

const CARD_SEVERITIES = [
'info',
Expand All @@ -27,19 +25,22 @@ const CARD_SEVERITIES = [

type CornerSize = 'medium' | 'large'
type CardFillLevel = Exclude<FillLevel, 0>
type CardHue = (typeof HUES)[number]
type CardSeverity = Extract<SeverityExt, (typeof CARD_SEVERITIES)[number]>

type BaseCardProps = {
/** @deprecated Colors set by `FillLevelContext`. If you need to override context, use `fillLevel` */
hue?: CardHue
/** Used to override a fill level set by `FillLevelContext` */
fillLevel?: FillLevel
cornerSize?: CornerSize
clickable?: boolean
disabled?: boolean
selected?: boolean
severity?: SeverityExt
header?: {
size?: 'medium' | 'large'
content?: ReactNode
headerProps?: ComponentProps<'div'>
outerProps?: ComponentProps<'div'>
}
}

type CardProps = DivProps & BaseCardProps
Expand All @@ -65,35 +66,21 @@ const fillToNeutralHoverBgC = {
3: 'fill-three-hover',
} as const satisfies Record<FillLevel, keyof DefaultTheme['colors']>

const hueToFill = {
default: 1,
lighter: 2,
lightest: 3,
} as const satisfies Record<CardHue, CardFillLevel>

const fillToNeutralSelectedBgC = {
0: 'fill-one-selected',
1: 'fill-one-selected',
2: 'fill-two-selected',
3: 'fill-three-selected',
} as const satisfies Record<FillLevel, keyof DefaultTheme['colors']>

export function useDecideFillLevel({
hue,
fillLevel,
}: {
hue?: CardHue
fillLevel?: number
}) {
export function useDecideFillLevel({ fillLevel }: { fillLevel?: number }) {
const parentFillLevel = useFillLevel()

if (isFillLevel(fillLevel)) {
return toFillLevel(Math.max(1, fillLevel)) as CardFillLevel
}

return isFillLevel(hueToFill[hue])
? hueToFill[hue]
: (toFillLevel(parentFillLevel + 1) as CardFillLevel)
return (
typeof fillLevel === 'number'
? toFillLevel(Math.max(1, fillLevel))
: toFillLevel(parentFillLevel + 1)
) as CardFillLevel
}

export const getFillToLightBgC = memoize(
Expand Down Expand Up @@ -151,7 +138,38 @@ const getBgColor = ({
return fillToLightBgC[severity][fillLevel]
}

const HeaderSC = styled.div<{
$fillLevel: CardFillLevel
$selected: boolean
$size: 'medium' | 'large'
$cornerSize: CornerSize
}>(
({
theme,
$fillLevel: fillLevel,
$selected: selected,
$size: size,
$cornerSize: cornerSize,
}) => ({
...theme.partials.text.overline,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
color: theme.colors['text-xlight'],
border: `1px solid ${theme.colors[fillToNeutralBorderC[fillLevel]]}`,
borderBottom: 'none',
borderRadius: `${theme.borderRadiuses[cornerSize]}px ${theme.borderRadiuses[cornerSize]}px 0 0`,
backgroundColor: selected
? theme.colors[fillToNeutralSelectedBgC[fillLevel]]
: getBgColor({ theme, fillLevel }),
height: size === 'large' ? 48 : 40,
padding: `0 ${theme.spacing.medium}px`,
overflow: 'hidden',
})
)

const CardSC = styled(Div)<{
$hasHeader: boolean
$fillLevel: CardFillLevel
$cornerSize: CornerSize
$severity: Severity
Expand All @@ -161,6 +179,7 @@ const CardSC = styled(Div)<{
}>(
({
theme,
$hasHeader,
$fillLevel: fillLevel,
$cornerSize: cornerSize,
$severity: severity,
Expand All @@ -169,8 +188,16 @@ const CardSC = styled(Div)<{
$disabled: disabled,
}) => ({
...theme.partials.reset.button,
border: `1px solid ${theme.colors[fillToNeutralBorderC[fillLevel]]}`,
borderRadius: theme.borderRadiuses[cornerSize],
border: `1px solid ${
theme.colors[
fillToNeutralBorderC[
$hasHeader ? toFillLevel(fillLevel + 1) : fillLevel
]
]
}`,
borderRadius: $hasHeader
? `0 0 ${theme.borderRadiuses[cornerSize]}px ${theme.borderRadiuses[cornerSize]}px`
: theme.borderRadiuses[cornerSize],
backgroundColor: selected
? theme.colors[fillToNeutralSelectedBgC[fillLevel]]
: getBgColor({ theme, fillLevel }),
Expand All @@ -196,47 +223,85 @@ const CardSC = styled(Div)<{
})
)

const OuterWrapSC = styled.div({
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
width: '100%',
height: '100%',
})

const Card = forwardRef(
(
{
header,
cornerSize = 'large',
hue, // Deprecated, prefer fillLevel
severity = 'neutral',
fillLevel,
selected = false,
clickable = false,
disabled = false,
children,
...props
}: CardProps,
ref
) => {
fillLevel = useDecideFillLevel({ hue, fillLevel })
const hasHeader = !!header
const {
size,
content: headerContent,
headerProps,
outerProps,
} = header ?? {}

const mainFillLevel = useDecideFillLevel({ fillLevel })
const headerFillLevel = useDecideFillLevel({ fillLevel: mainFillLevel + 1 })

const cardSeverity = sanitizeSeverity(severity, {
allowList: CARD_SEVERITIES,
default: 'neutral',
})

return (
<FillLevelProvider value={fillLevel}>
<CardSC
ref={ref}
$cornerSize={cornerSize}
$fillLevel={fillLevel}
$severity={cardSeverity}
$selected={selected}
$clickable={clickable}
{...(clickable && {
forwardedAs: 'button',
type: 'button',
'data-clickable': 'true',
})}
$disabled={clickable && disabled}
{...props}
/>
<FillLevelProvider value={mainFillLevel}>
<WrapWithIf
condition={hasHeader}
wrapper={<OuterWrapSC {...outerProps} />}
>
{header && (
<HeaderSC
$fillLevel={headerFillLevel}
$selected={selected}
$size={size}
$cornerSize={cornerSize}
{...headerProps}
>
{headerContent}
</HeaderSC>
)}
<CardSC
ref={ref}
$cornerSize={cornerSize}
$fillLevel={mainFillLevel}
$severity={cardSeverity}
$selected={selected}
$clickable={clickable}
$hasHeader={hasHeader}
{...(clickable && {
forwardedAs: 'button',
type: 'button',
'data-clickable': 'true',
})}
$disabled={clickable && disabled}
{...props}
>
{children}
</CardSC>
</WrapWithIf>
</FillLevelProvider>
)
}
)

export default Card
export type { BaseCardProps, CardProps, CornerSize, CardHue, CardFillLevel }
export type { BaseCardProps, CardFillLevel, CardProps, CornerSize }
27 changes: 20 additions & 7 deletions src/components/Chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ import Tooltip from './Tooltip'
export const CHIP_CLOSE_ATTR_KEY = 'data-close-button' as const
const SIZES = ['small', 'medium', 'large'] as const

type ChipSize = (typeof SIZES)[number]
type ChipSeverity = (typeof SEVERITIES)[number]
export type ChipSize = (typeof SIZES)[number]
export type ChipSeverity = (typeof SEVERITIES)[number]

export type ChipProps = Omit<FlexProps, 'size'> &
BaseCardProps & {
size?: ChipSize
condensed?: boolean
severity?: ChipSeverity
inactive?: boolean
icon?: ReactElement
loading?: boolean
closeButton?: boolean
Expand Down Expand Up @@ -73,15 +74,26 @@ const sizeToCloseHeight = {
const ChipCardSC = styled(Card)<{
$size: ChipSize
$severity: ChipSeverity
$inactive: boolean
$truncateWidth?: number
$truncateEdge?: 'start' | 'end'
$condensed?: boolean
}>(({ $size, $severity, $truncateWidth, $truncateEdge, $condensed, theme }) => {
const textColor =
theme.colors[severityToColor[$severity]] || theme.colors.text
}>(({
$size,
$severity,
$inactive,
$truncateWidth,
$truncateEdge,
$condensed,
theme,
}) => {
const textColor = $inactive
? theme.colors['text-xlight']
: theme.colors[severityToColor[$severity]] ?? theme.colors.text

return {
'&&': {
backgroundColor: $inactive ? 'transparent' : undefined,
padding: `${$size === 'large' ? 6 : theme.spacing.xxxsmall}px ${
$size === 'large' && $condensed
? 6
Expand Down Expand Up @@ -164,9 +176,9 @@ function ChipRef(
size = 'medium',
condensed = false,
severity = 'neutral',
inactive = false,
truncateWidth,
truncateEdge,
hue,
fillLevel,
loading = false,
icon,
Expand All @@ -180,7 +192,7 @@ function ChipRef(
}: ChipProps,
ref: Ref<any>
) {
fillLevel = useDecideFillLevel({ hue, fillLevel })
fillLevel = useDecideFillLevel({ fillLevel })
const theme = useTheme()

const iconCol = severityToIconColor[severity] || 'icon-default'
Expand All @@ -193,6 +205,7 @@ function ChipRef(
fillLevel={fillLevel}
clickable={clickable}
disabled={clickable && disabled}
$inactive={inactive}
$size={size}
$condensed={condensed}
$severity={severity}
Expand Down
2 changes: 1 addition & 1 deletion src/components/IconFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const sizeToIconSize: Record<Size, number> = {
xsmall: 8,
small: 16,
medium: 16,
large: 24,
large: 16,
xlarge: 24,
}

Expand Down
Loading
Loading