diff --git a/packages/ui/src/Pill/index.stories.tsx b/packages/ui/src/Pill/index.stories.tsx new file mode 100644 index 0000000000..b5cbd08eef --- /dev/null +++ b/packages/ui/src/Pill/index.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Pill } from '.'; + +const meta: Meta = { + component: Pill, + tags: ['autodocs', '!dev'], +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + children: 'Pill', + size: 'sparse', + priority: 'primary', + }, +}; diff --git a/packages/ui/src/Pill/index.test.tsx b/packages/ui/src/Pill/index.test.tsx new file mode 100644 index 0000000000..ba4b6540aa --- /dev/null +++ b/packages/ui/src/Pill/index.test.tsx @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { Pill } from '.'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '../ThemeProvider'; + +describe('', () => { + it('renders its `children`', () => { + const { queryByText } = render(Contents, { wrapper: ThemeProvider }); + + expect(queryByText('Contents')).toBeTruthy(); + }); +}); diff --git a/packages/ui/src/Pill/index.tsx b/packages/ui/src/Pill/index.tsx new file mode 100644 index 0000000000..797747e83a --- /dev/null +++ b/packages/ui/src/Pill/index.tsx @@ -0,0 +1,43 @@ +import styled from 'styled-components'; +import { asTransientProps } from '../utils/asTransientProps'; +import { ReactNode } from 'react'; +import { button } from '../utils/typography'; + +type Size = 'sparse' | 'dense'; +type Priority = 'primary' | 'secondary'; + +const TEN_PERCENT_OPACITY_IN_HEX = '1a'; + +const Root = styled.span<{ $size: Size; $priority: Priority }>` + ${button} + + box-sizing: border-box; + border: 2px dashed + ${props => + props.$priority === 'secondary' ? props.theme.color.other.tonalStroke : 'transparent'}; + border-radius: ${props => props.theme.borderRadius.full}; + + display: inline-block; + max-width: 100%; + + padding-top: ${props => props.theme.spacing(props.$size === 'sparse' ? 2 : 1)}; + padding-bottom: ${props => props.theme.spacing(props.$size === 'sparse' ? 2 : 1)}; + + padding-left: ${props => props.theme.spacing(props.$size === 'sparse' ? 4 : 2)}; + padding-right: ${props => props.theme.spacing(props.$size === 'sparse' ? 4 : 2)}; + + background-color: ${props => + props.$priority === 'primary' + ? props.theme.color.text.primary + TEN_PERCENT_OPACITY_IN_HEX + : 'transparent'}; +`; + +export interface PillProps { + children: ReactNode; + size?: Size; + priority?: Priority; +} + +export const Pill = ({ children, size = 'sparse', priority = 'primary' }: PillProps) => ( + {children} +); diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/DelegationTokenIcon.tsx b/packages/ui/src/ValueViewComponent/AssetIcon/DelegationTokenIcon.tsx new file mode 100644 index 0000000000..db7a46a802 --- /dev/null +++ b/packages/ui/src/ValueViewComponent/AssetIcon/DelegationTokenIcon.tsx @@ -0,0 +1,111 @@ +import { assetPatterns } from '@penumbra-zone/types/assets'; +import styled from 'styled-components'; + +const Svg = styled.svg.attrs({ + id: 'delegation', + xmlns: 'http://www.w3.org/2000/svg', + xmlnsXlink: 'http://www.w3.org/1999/xlink', + viewBox: '0 0 32 32', +})` + display: block; + border-radius: ${props => props.theme.borderRadius.full}; + width: 24px; + height: 24px; +`; + +const getFirstEightCharactersOfValidatorId = (displayDenom = ''): [string, string] => { + const id = (assetPatterns.delegationToken.capture(displayDenom)?.id ?? '').substring(0, 8); + + const firstFour = id.substring(0, 4); + const lastFour = id.substring(4); + + return [firstFour, lastFour]; +}; + +export interface DelegationTokenIconProps { + displayDenom?: string; +} + +export const DelegationTokenIcon = ({ displayDenom }: DelegationTokenIconProps) => { + const [firstFour, lastFour] = getFirstEightCharactersOfValidatorId(displayDenom); + + return ( + + + + + + + + + + + + + + {firstFour} + + + {lastFour} + + + + + + + + + ); +}; diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/generate.ts b/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/generate.ts new file mode 100644 index 0000000000..500c56120c --- /dev/null +++ b/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/generate.ts @@ -0,0 +1,42 @@ +// Inspired by: https://github.com/vercel/avatar + +import color from 'tinycolor2'; +import Murmur from 'murmurhash3js'; + +// Deterministically getting a gradient from a string for use as an identicon +export const generateGradient = (str: string) => { + // Get first color + const hash = Murmur.x86.hash32(str); + const c = color({ h: hash % 360, s: 0.95, l: 0.5 }); + + const tetrad = c.tetrad(); // 4 colors spaced around the color wheel, the first being the input + const secondColorOptions = tetrad.slice(1); + const index = hash % 3; + const toColor = secondColorOptions[index]!.toHexString(); + + return { + fromColor: c.toHexString(), + toColor, + }; +}; + +export const generateSolidColor = (str: string) => { + // Get color + const hash = Murmur.x86.hash32(str); + const c = color({ h: hash % 360, s: 0.95, l: 0.5 }) + .saturate(0) + .darken(20); + return { + bg: c.toHexString(), + // get readable text color + text: color + .mostReadable(c, ['white', 'black'], { + includeFallbackColors: true, + level: 'AAA', + size: 'small', + }) + .saturate() + .darken(20) + .toHexString(), + }; +}; diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx b/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx new file mode 100644 index 0000000000..7b30f26d11 --- /dev/null +++ b/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +import { generateGradient, generateSolidColor } from './generate'; +import styled from 'styled-components'; + +const Svg = styled.svg.attrs<{ $size: number }>(props => ({ + width: props.$size, + height: props.$size, + viewBox: `0 0 ${props.$size} ${props.$size}`, + version: '1.1', + xmlns: 'http://www.w3.org/2000/svg', +}))` + display: block; + border-radius: ${props => props.theme.borderRadius.full}; +`; + +export interface IdenticonProps { + uniqueIdentifier: string; + size?: number; + className?: string; + type: 'gradient' | 'solid'; +} + +export const Identicon = (props: IdenticonProps) => { + if (props.type === 'gradient') { + return ; + } + return ; +}; + +const IdenticonGradient = ({ uniqueIdentifier, size = 120 }: IdenticonProps) => { + const gradient = useMemo(() => generateGradient(uniqueIdentifier), [uniqueIdentifier]); + const gradientId = useMemo(() => `gradient-${uniqueIdentifier}`, [uniqueIdentifier]); + + return ( + + + + + + + + + + + + ); +}; + +const IdenticonSolid = ({ uniqueIdentifier, size = 120 }: IdenticonProps) => { + const color = useMemo(() => generateSolidColor(uniqueIdentifier), [uniqueIdentifier]); + + return ( + + + + {uniqueIdentifier[0]} + + + ); +}; diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/UnbondingTokenIcon.tsx b/packages/ui/src/ValueViewComponent/AssetIcon/UnbondingTokenIcon.tsx new file mode 100644 index 0000000000..e2a848e77c --- /dev/null +++ b/packages/ui/src/ValueViewComponent/AssetIcon/UnbondingTokenIcon.tsx @@ -0,0 +1,111 @@ +import { assetPatterns } from '@penumbra-zone/types/assets'; +import styled from 'styled-components'; + +const Svg = styled.svg.attrs({ + id: 'unbonding', + xmlns: 'http://www.w3.org/2000/svg', + xmlnsXlink: 'http://www.w3.org/1999/xlink', + viewBox: '0 0 32 32', +})` + display: block; + border-radius: ${props => props.theme.borderRadius.full}; + width: 24px; + height: 24px; +`; + +const getFirstEightCharactersOfValidatorId = (displayDenom = ''): [string, string] => { + const id = (assetPatterns.unbondingToken.capture(displayDenom)?.id ?? '').substring(0, 8); + + const firstFour = id.substring(0, 4); + const lastFour = id.substring(4); + + return [firstFour, lastFour]; +}; + +export interface UnbondingTokenIconProps { + displayDenom?: string; +} + +export const UnbondingTokenIcon = ({ displayDenom }: UnbondingTokenIconProps) => { + const [firstFour, lastFour] = getFirstEightCharactersOfValidatorId(displayDenom); + + return ( + + + + + + + + + + + + + + {firstFour} + + + {lastFour} + + + + + + + + + ); +}; diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/index.tsx b/packages/ui/src/ValueViewComponent/AssetIcon/index.tsx new file mode 100644 index 0000000000..83bff060f3 --- /dev/null +++ b/packages/ui/src/ValueViewComponent/AssetIcon/index.tsx @@ -0,0 +1,47 @@ +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb.js'; +import { Identicon } from './Identicon'; +import { DelegationTokenIcon } from './DelegationTokenIcon'; +import { getDisplay } from '@penumbra-zone/getters/metadata'; +import { assetPatterns } from '@penumbra-zone/types/assets'; +import { UnbondingTokenIcon } from './UnbondingTokenIcon'; +import styled from 'styled-components'; + +const IconImg = styled.img` + display: block; + border-radius: ${props => props.theme.borderRadius.full}; + width: 24px; + height: 24px; +`; + +export interface AssetIcon { + metadata?: Metadata; + size?: 'sparse' | 'dense'; +} + +export const AssetIcon = ({ metadata }: AssetIcon) => { + // Image default is "" and thus cannot do nullish-coalescing + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const icon = metadata?.images[0]?.png || metadata?.images[0]?.svg; + const display = getDisplay.optional()(metadata); + const isDelegationToken = display ? assetPatterns.delegationToken.matches(display) : false; + const isUnbondingToken = display ? assetPatterns.unbondingToken.matches(display) : false; + + return ( + <> + {icon ? ( + + ) : isDelegationToken ? ( + + ) : isUnbondingToken ? ( + /** + * @todo: Render a custom unbonding token for validators that have a + * logo -- e.g., with the validator ID superimposed over the validator + * logo. + */ + + ) : ( + + )} + + ); +}; diff --git a/packages/ui/src/ValueViewComponent/index.stories.tsx b/packages/ui/src/ValueViewComponent/index.stories.tsx new file mode 100644 index 0000000000..aacd4c813b --- /dev/null +++ b/packages/ui/src/ValueViewComponent/index.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ValueViewComponent } from '.'; +import { + DELEGATION_VALUE_VIEW, + PENUMBRA_VALUE_VIEW, + UNBONDING_VALUE_VIEW, + UNKNOWN_ASSET_ID_VALUE_VIEW, + UNKNOWN_ASSET_VALUE_VIEW, +} from './sampleValueViews'; + +const meta: Meta = { + component: ValueViewComponent, + tags: ['autodocs', '!dev'], + argTypes: { + valueView: { + options: [ + 'Penumbra', + 'Delegation token', + 'Unbonding token', + 'Unknown asset', + 'Unknown asset ID', + ], + mapping: { + Penumbra: PENUMBRA_VALUE_VIEW, + 'Delegation token': DELEGATION_VALUE_VIEW, + 'Unbonding token': UNBONDING_VALUE_VIEW, + 'Unknown asset': UNKNOWN_ASSET_VALUE_VIEW, + 'Unknown asset ID': UNKNOWN_ASSET_ID_VALUE_VIEW, + }, + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + valueView: PENUMBRA_VALUE_VIEW, + context: 'default', + size: 'sparse', + priority: 'primary', + }, +}; diff --git a/packages/ui/src/ValueViewComponent/index.test.tsx b/packages/ui/src/ValueViewComponent/index.test.tsx new file mode 100644 index 0000000000..2f064e2cb6 --- /dev/null +++ b/packages/ui/src/ValueViewComponent/index.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { ValueViewComponent } from '.'; +import { render } from '@testing-library/react'; +import { + PENUMBRA_VALUE_VIEW, + UNKNOWN_ASSET_ID_VALUE_VIEW, + UNKNOWN_ASSET_VALUE_VIEW, +} from './sampleValueViews'; +import { ThemeProvider } from '../ThemeProvider'; + +describe('', () => { + it('renders the formatted amount and symbol', () => { + const { container } = render(, { + wrapper: ThemeProvider, + }); + + expect(container).toHaveTextContent('123 UM'); + }); + + it("renders 'Unknown' for metadata without a symbol", () => { + const { container } = render(, { + wrapper: ThemeProvider, + }); + + expect(container).toHaveTextContent('123,000,000 Unknown'); + }); + + it("renders 'Unknown' for a value view with a `case` of `unknownAssetId`", () => { + const { container } = render(, { + wrapper: ThemeProvider, + }); + + expect(container).toHaveTextContent('123,000,000 Unknown'); + }); +}); diff --git a/packages/ui/src/ValueViewComponent/index.tsx b/packages/ui/src/ValueViewComponent/index.tsx new file mode 100644 index 0000000000..4685aa1263 --- /dev/null +++ b/packages/ui/src/ValueViewComponent/index.tsx @@ -0,0 +1,118 @@ +import { ValueView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { ConditionalWrap } from '../utils/ConditionalWrap'; +import { Pill } from '../Pill'; +import { Text } from '../Text'; +import styled from 'styled-components'; +import { AssetIcon } from './AssetIcon'; +import { getMetadata } from '@penumbra-zone/getters/value-view'; +import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; + +type Context = 'default' | 'table'; + +const Row = styled.span<{ $context: Context; $priority: 'primary' | 'secondary' }>` + display: flex; + gap: ${props => props.theme.spacing(2)}; + align-items: center; + width: min-content; + max-width: 100%; + text-overflow: ellipsis; + + ${props => + props.$context === 'table' && props.$priority === 'secondary' + ? ` + border-bottom: 2px dashed ${props.theme.color.other.tonalStroke}; + padding-bottom: ${props.theme.spacing(2)}; + ` + : ''}; +`; + +const AssetIconWrapper = styled.div` + flex-shrink: 0; +`; + +const PillMarginOffsets = styled.div<{ $size: 'dense' | 'sparse' }>` + margin-left: ${props => props.theme.spacing(props.$size === 'sparse' ? -2 : -1)}; + margin-right: ${props => props.theme.spacing(props.$size === 'sparse' ? -1 : 0)}; +`; + +const Content = styled.div` + flex-grow: 1; + flex-shrink: 1; + + display: flex; + gap: ${props => props.theme.spacing(2)}; + align-items: center; + + overflow: hidden; +`; + +const SymbolWrapper = styled.div` + flex-grow: 1; + flex-shrink: 1; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export interface ValueViewComponentProps { + valueView: ValueView; + /** + * A `ValueViewComponent` will be rendered differently depending on which + * context it's rendered in. By default, it'll be rendered in a pill. But in a + * table context, it'll be rendered as just an icon and text. + */ + context?: SelectedContext; + /** + * Can only be set when the `context` is `default`. For the `table` context, + * there is only one size (`sparse`). + */ + size?: SelectedContext extends 'table' ? 'sparse' : 'dense' | 'sparse'; + /** + * Use `primary` in most cases, or `secondary` when this value view + * represents a secondary value, such as when it's an equivalent value of a + * numeraire. + */ + priority?: 'primary' | 'secondary'; +} + +/** + * `ValueViewComponent` renders a `ValueView` — its amount, icon, and symbol. + * Use this anywhere you would like to render a `ValueView`. + */ +export const ValueViewComponent = ({ + valueView, + context, + size = 'sparse', + priority = 'primary', +}: ValueViewComponentProps) => { + const formattedAmount = getFormattedAmtFromValueView(valueView, true); + const metadata = getMetadata.optional()(valueView); + // Symbol default is "" and thus cannot do nullish coalescing + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const symbol = metadata?.symbol || 'Unknown'; + + return ( + ( + + {children} + + )} + > + + + + + + + {formattedAmount} + + {symbol} + + + + + ); +}; diff --git a/packages/ui/src/ValueViewComponent/sampleValueViews.ts b/packages/ui/src/ValueViewComponent/sampleValueViews.ts new file mode 100644 index 0000000000..48e7a88f9e --- /dev/null +++ b/packages/ui/src/ValueViewComponent/sampleValueViews.ts @@ -0,0 +1,111 @@ +import { + Metadata, + ValueView, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { bech32mIdentityKey } from '@penumbra-zone/bech32m/penumbravalid'; + +const u8 = (length: number) => Uint8Array.from({ length }, () => Math.floor(Math.random() * 256)); +const validatorIk = { ik: u8(32) }; +const validatorIkString = bech32mIdentityKey(validatorIk); +const delString = 'delegation_' + validatorIkString; +const udelString = 'udelegation_' + validatorIkString; +const delAsset = { inner: u8(32) }; +const unbondString = 'unbonding_start_at_123_' + validatorIkString; +const uunbondString = 'uunbonding_start_at_123_' + validatorIkString; +const unbondAsset = { inner: u8(32) }; + +const DELEGATION_TOKEN_METADATA = new Metadata({ + display: delString, + base: udelString, + denomUnits: [{ denom: udelString }, { denom: delString, exponent: 6 }], + name: 'Delegation token', + penumbraAssetId: delAsset, + symbol: `delUM(${validatorIkString})`, +}); + +const UNBONDING_TOKEN_METADATA = new Metadata({ + display: unbondString, + base: uunbondString, + denomUnits: [{ denom: uunbondString }, { denom: unbondString, exponent: 6 }], + name: 'Unbonding token', + penumbraAssetId: unbondAsset, + symbol: `unbondUMat123(${validatorIkString})`, +}); + +const PENUMBRA_METADATA = new Metadata({ + denomUnits: [ + { + denom: 'penumbra', + exponent: 6, + }, + { + denom: 'mpenumbra', + exponent: 3, + }, + { + denom: 'upenumbra', + }, + ], + base: 'upenumbra', + display: 'penumbra', + symbol: 'UM', + penumbraAssetId: { + altBaseDenom: 'KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA=', + }, + images: [ + { + svg: 'https://raw.githubusercontent.com/prax-wallet/registry/main/images/um.svg', + }, + ], +}); + +export const PENUMBRA_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + metadata: PENUMBRA_METADATA, + }, + }, +}); + +export const DELEGATION_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + metadata: DELEGATION_TOKEN_METADATA, + }, + }, +}); + +export const UNBONDING_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + metadata: UNBONDING_TOKEN_METADATA, + }, + }, +}); + +export const UNKNOWN_ASSET_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + metadata: { + penumbraAssetId: { inner: new Uint8Array([]) }, + }, + }, + }, +}); + +export const UNKNOWN_ASSET_ID_VALUE_VIEW = new ValueView({ + valueView: { + case: 'unknownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + }, + }, +}); diff --git a/packages/ui/src/utils/ConditionalWrap.tsx b/packages/ui/src/utils/ConditionalWrap.tsx new file mode 100644 index 0000000000..3b6f53762d --- /dev/null +++ b/packages/ui/src/utils/ConditionalWrap.tsx @@ -0,0 +1,60 @@ +import { ReactNode } from 'react'; + +export interface ConditionalWrapProps { + if: boolean; + then: (children: ReactNode) => ReactNode; + else?: (children: ReactNode) => ReactNode; + children: ReactNode; +} + +/** + * Internal utility component to optionally wrap a React component with another + * React component, depending on a condition. + * + * @example + * ```tsx + * ( + * + * {children} + * Here is the tooltip text. + * + * )} + * > + * Here is the content that may or may not need a tooltip. + * + * ``` + * + * You can also pass an `else` prop to wrap the `children` if the condition is + * _not_ met. + * + * @example + * ```tsx + * ( + * + * {children} + * Here is the tooltip text. + * + * )} + * else={(children) => ( + * {children} + * )} + * > + * Here is the content that may or may not need a tooltip. + * + * ``` + * + * @see https://stackoverflow.com/a/56870316/974981 + */ +export const ConditionalWrap = ({ + children, + + // Rename these to avoid using reserved words + if: condition, + then: thenWrapper, + else: elseWrapper, +}: ConditionalWrapProps) => + condition ? thenWrapper(children) : elseWrapper ? elseWrapper(children) : children;