Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ const DetailInfoEstLpTokens = ({
isBold
loading={formLpTokenExpected.loading}
loadingSkeleton={[85, 23]}
className={formLpTokenExpected.expected.length > 20 ? 'isRow' : ''}
label={t`Minimum LP Tokens:`}
tooltip={
showTooltip ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ const LoanInfoParameters = ({ llamma, llammaId }: Props) => {

return (
<Box grid gridRowGap="1">
<DetailInfo label={t`Band width factor`} size="md">
<DetailInfo label={t`Band width factor`}>
<span>{formatNumber(llamma?.A, { useGrouping: false })}</span>
</DetailInfo>
<DetailInfo label={t`Base price`} size="md">
<DetailInfo label={t`Base price`}>
{typeof loanDetails?.basePrice !== 'undefined' && (
<Chip
size="md"
Expand All @@ -29,7 +29,7 @@ const LoanInfoParameters = ({ llamma, llammaId }: Props) => {
</Chip>
)}
</DetailInfo>
<DetailInfo label={t`Oracle price`} size="md">
<DetailInfo label={t`Oracle price`}>
{typeof priceInfo?.oraclePrice !== 'undefined' && (
<Chip
size="md"
Expand All @@ -40,7 +40,7 @@ const LoanInfoParameters = ({ llamma, llammaId }: Props) => {
</Chip>
)}
</DetailInfo>
<DetailInfo label={t`Borrow rate`} size="md">
<DetailInfo label={t`Borrow rate`}>
{typeof parameters?.rate !== 'undefined' && (
<Chip
size="md"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const AdvancedDetails = () => (
label={t`Vault Contract Address`}
value={shortenAddress(SCRVUSD_VAULT_ADDRESS)}
link={networks[Chain.Ethereum].scanAddressPath(SCRVUSD_VAULT_ADDRESS)}
copy
copyValue={SCRVUSD_VAULT_ADDRESS}
copiedTitle={t`Vault contract address copied!`}
/>
</Stack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export const PegKeeperAdvancedDetails = ({
label={t`Pool`}
value={shortenAddress(poolAddress, { digits: 2 })}
link={getInternalUrl('dex', 'ethereum', `${DEX_ROUTES.PAGE_POOLS}/${poolId}/deposit`)}
copy
copyValue={poolAddress}
testId={`${testId}-action-info-pool`}
/>
Expand All @@ -62,7 +61,6 @@ export const PegKeeperAdvancedDetails = ({
label={t`Contract`}
value={shortenAddress(address, { digits: 2 })}
link={`https://etherscan.io/address/${address}`}
copy
copyValue={address}
testId={`${testId}-action-info-contract`}
/>
Expand Down
75 changes: 39 additions & 36 deletions packages/curve-ui-kit/src/shared/ui/ActionInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,78 +7,83 @@ import AlertTitle from '@mui/material/AlertTitle'
import IconButton from '@mui/material/IconButton'
import Link from '@mui/material/Link'
import Snackbar from '@mui/material/Snackbar'
import Stack from '@mui/material/Stack'
import Stack, { type StackProps } from '@mui/material/Stack'
import Typography, { type TypographyProps } from '@mui/material/Typography'
import { useSwitch } from '@ui-kit/hooks/useSwitch'
import { t } from '@ui-kit/lib/i18n'
import { ExclamationTriangleIcon } from '@ui-kit/shared/icons/ExclamationTriangleIcon'
import { RouterLink } from '@ui-kit/shared/ui/RouterLink'
import { WithTooltip } from '@ui-kit/shared/ui/WithTooltip'
import { Duration } from '@ui-kit/themes/design/0_primitives'
import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
import type { TypographyVariantKey } from '@ui-kit/themes/typography'
import { copyToClipboard, type SxProps } from '@ui-kit/utils'
import { copyToClipboard } from '@ui-kit/utils'
import { Tooltip } from './Tooltip'
import { WithSkeleton } from './WithSkeleton'

const { Spacing, IconSize } = SizesAndSpaces
const MOCK_SKELETON = 10 // Mock value for skeleton to infer some width

type ComponentSize = 'small' | 'medium' | 'large'
export type ActionInfoSize = 'small' | 'medium' | 'large'

export type ActionInfoProps = {
/** Label displayed on the left side */
label: string
label: ReactNode
/** Custom color for the label text */
labelColor?: TypographyProps['color']
/** Primary value to display and copy */
value: string
value: ReactNode
/** Custom color for the value text */
valueColor?: TypographyProps['color']
/** Optional content to display to the left of the value */
valueLeft?: ReactNode
/** Optional content to display to the right of the value */
valueRight?: ReactNode
/** Tooltip text to display when hovering over the value */
valueTooltip?: string
valueTooltip?: ReactNode
/** Previous value (if needed for comparison) */
prevValue?: string
/** Custom color for the previous value text */
prevValueColor?: TypographyProps['color']
/** URL to navigate to when clicking the external link button */
link?: string
/** Whether or not the value can be copied */
copy?: boolean
/** Value to be copied. Example use case would be copying the full address when the value is formatted / shortened. Defaults to original value. */
/** Value to be copied (will display a copy button). */
copyValue?: string
/** Message displayed in the snackbar title when the value is copied */
copiedTitle?: string
/** Size of the component */
size?: ComponentSize
/** Whether the component is in a loading state. Can be boolean or string (string value is used for skeleton width inference) */
loading?: boolean | string
size?: ActionInfoSize
/** Whether the component is in a loading state. Can be one of:
* - boolean
* - string (value is used for skeleton width inference)
* - [number, number] (explicit skeleton width and height in px)
**/
loading?: boolean | [number, number] | string
/** Error state; Unused for now, but kept for future use */
error?: boolean | Error | null
error?: boolean | Error | string | null
/** Test ID for the component */
testId?: string
sx?: SxProps
/** Additional styles */
sx?: StackProps['sx']
}

const labelSize = {
small: 'bodyXsRegular',
medium: 'bodyMRegular',
large: 'bodyMRegular',
} as const satisfies Record<ComponentSize, TypographyVariantKey>
} as const satisfies Record<ActionInfoSize, TypographyVariantKey>

const prevValueSize = {
small: 'bodySRegular',
medium: 'bodyMRegular',
large: 'bodyMRegular',
} as const satisfies Record<ComponentSize, TypographyVariantKey>
} as const satisfies Record<ActionInfoSize, TypographyVariantKey>

const valueSize = {
small: 'bodyXsBold',
medium: 'highlightM',
large: 'headingSBold',
} as const satisfies Record<ComponentSize, TypographyVariantKey>
} as const satisfies Record<ActionInfoSize, TypographyVariantKey>

const ActionInfo = ({
label,
Expand All @@ -92,24 +97,23 @@ const ActionInfo = ({
valueTooltip = '',
link,
size = 'medium',
copy = false,
copyValue: givenCopyValue,
copyValue,
copiedTitle,
loading = false,
error = false,
testId = 'action-info',
sx,
}: ActionInfoProps) => {
const [isSnackbarOpen, openSnackbar, closeSnackbar] = useSwitch(false)
const copyValue = (givenCopyValue ?? value).trim()

const copyAndShowSnackbar = useCallback(() => {
void copyToClipboard(copyValue)
void copyToClipboard(copyValue!.trim())
openSnackbar()
}, [copyValue, openSnackbar])

const errorMessage = (typeof error === 'object' && error?.message) || (typeof error === 'string' && error)
return (
<Stack direction="row" alignItems="center" gap={Spacing.sm} sx={sx} data-testid={testId}>
<Stack direction="row" alignItems="center" gap={Spacing.sm} data-testid={testId} sx={sx}>
<Typography flexGrow={1} variant={labelSize[size]} color={labelColor ?? 'textSecondary'} textAlign="start">
{label}
</Typography>
Expand All @@ -135,32 +139,31 @@ const ActionInfo = ({
/>
)}

<Tooltip title={(typeof error === 'object' && error?.message) || valueTooltip} placement="top">
<Tooltip title={valueTooltip} placement="top">
{/** Additional stack to add some space between left (icon), value and right (icon) */}
<Stack direction="row" alignItems="center" gap={Spacing.xxs} data-testid={`${testId}-value`}>
{valueLeft}

<WithSkeleton loading={!!loading}>
<WithSkeleton
loading={!!loading}
{...(Array.isArray(loading) && { width: loading[0], height: loading[1] })}
>
<Typography variant={valueSize[size]} color={error ? 'error' : (valueColor ?? 'textPrimary')}>
{loading ? (
typeof loading === 'string' ? (
loading
) : (
MOCK_SKELETON
)
) : error ? (
<ExclamationTriangleIcon fontSize="small" />
) : (
value
)}
{loading ? (typeof loading === 'string' ? loading : MOCK_SKELETON) : value}
</Typography>
</WithSkeleton>

{valueRight}
</Stack>
</Tooltip>

{copy && copyValue && (
{error && (
<WithTooltip title={errorMessage} placement="top">
<ExclamationTriangleIcon fontSize="small" color="error" />
</WithTooltip>
)}

{copyValue && (
<IconButton size="extraSmall" title={copyValue} onClick={copyAndShowSnackbar} color="primary">
<ContentCopy />
</IconButton>
Expand Down
40 changes: 10 additions & 30 deletions packages/curve-ui-kit/src/shared/ui/WithSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,18 @@
import React from 'react'
import React, { ReactNode } from 'react'
import { default as Skeleton, SkeletonProps } from '@mui/material/Skeleton'
import { WithWrapper } from '@ui-kit/shared/ui/WithWrapper'

/**
* A component that wraps children with a Skeleton when loading.
* Useful for when you want to use the same child for dimension inference.
*
* @example
* ```tsx
* // Basic usage
* <WithSkeleton loading={isLoading}>
* <Typography>Content to show when loaded</Typography>
* </WithSkeleton>
*
* // With metrics
* <WithSkeleton loading={isDataLoading}>
* <Metric
* label="Total Value"
* value={totalValue}
* unit="$"
* />
* </WithSkeleton>
*
* // With custom skeleton props
* <WithSkeleton loading={isLoading} variant="rectangular" width={200} height={40}>
* <Typography>Content to show when loaded</Typography>
* </WithSkeleton>
* ```
*/
type WithSkeletonProps = {
/** Whether to show the skeleton or the children */
loading: boolean
/** Content to render when not loading, also used for skeleton dimension inference */
children: React.ReactNode
children: ReactNode
} & SkeletonProps

export const WithSkeleton = ({ loading, children, ...skeletonProps }: WithSkeletonProps) =>
loading ? <Skeleton {...skeletonProps}>{children}</Skeleton> : <>{children}</>
/**
* A component that wraps children with a Skeleton when loading.
* Useful for when you want to use the same child for dimension inference.
*/
export const WithSkeleton = ({ loading, ...skeletonProps }: WithSkeletonProps) => (
<WithWrapper Wrapper={Skeleton} wrap={loading} {...skeletonProps} />
)
10 changes: 10 additions & 0 deletions packages/curve-ui-kit/src/shared/ui/WithTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react'
import { default as Tooltip, TooltipProps } from '@mui/material/Tooltip'
import { WithWrapper } from '@ui-kit/shared/ui/WithWrapper'

/**
* A component that wraps children with a Tooltip when a title is provided.
* Useful for when you want to use the same child and don't know whether a tooltip is needed.
* The `title` passed to the Tooltip component is used to determine whether the tooltip should be rendered.
*/
export const WithTooltip = (props: TooltipProps) => <WithWrapper Wrapper={Tooltip} wrap={!!props.title} {...props} />
17 changes: 17 additions & 0 deletions packages/curve-ui-kit/src/shared/ui/WithWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React, { ReactNode } from 'react'

type WithWrapperProps<Props> = Props & {
/** Whether wrapper should be applied */
wrap: boolean
/** Component to use as a wrapper */
Wrapper: (props: Props & { children: ReactNode }) => ReactNode
/** Children to be wrapped */
children: ReactNode
}

/**
* A component that wraps children with a given Wrapper component when `wrap` is true.
* Useful for conditionally applying a wrapper component based on a boolean prop.
*/
export const WithWrapper = <Props,>({ wrap, Wrapper, children, ...wrapperProps }: WithWrapperProps<Props>) =>
wrap ? <Wrapper {...(wrapperProps as Props)}>{children}</Wrapper> : children
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ const meta: Meta<typeof ActionInfo> = {
control: 'text',
description: 'The URL to navigate to when clicking the external link button',
},
copy: {
control: 'boolean',
description: 'Whether or not the value can be copied',
copyValue: {
control: 'text',
description: 'The value to be copied (will display a copy button)',
},
copiedTitle: {
control: 'text',
Expand All @@ -73,7 +73,7 @@ const meta: Meta<typeof ActionInfo> = {
valueColor: 'textPrimary',
valueTooltip: 'Contract address',
link: 'https://etherscan.io/address/0x0655977feb2f289a4ab78af67bab0d17aab84367',
copy: true,
copyValue: '',
copiedTitle: 'Contract address copied!',
size: 'small',
loading: false,
Expand Down Expand Up @@ -172,7 +172,6 @@ export const WithEmptyValueAndSwitch: Story = {
value: '',
valueRight: <Switch size="small" />,
link: '',
copy: false,
size: 'medium',
},
parameters: {
Expand Down Expand Up @@ -202,7 +201,6 @@ export const WithError: Story = {
args: {
error: new Error('Failed to load contract address'),
size: 'medium',
copy: false,
link: '',
},
parameters: {
Expand Down
Loading