Skip to content

Commit

Permalink
Create <Pill />/<ValueViewComponent /> components (#1526)
Browse files Browse the repository at this point in the history
* Create a Pill component

* Start creating a ValueViewComponent

* Delete unused stories

* Show formatted amount and symbol

* Handle unknown assets

* Remove console log

* Write tests

* Rename variant -> priority

* Add priority prop; add docs

* Add more docs

* Fix various spacing issues

* Tweak icon size/spacing

* Remove background from secondary pill

* Move ConditionalWrap

* Handle text overflow more gracefully

* Update generate.ts to use Murmur
  • Loading branch information
jessepinho authored Jul 26, 2024
1 parent 887e228 commit 9da630e
Show file tree
Hide file tree
Showing 13 changed files with 821 additions and 0 deletions.
19 changes: 19 additions & 0 deletions packages/ui/src/Pill/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/react';

import { Pill } from '.';

const meta: Meta<typeof Pill> = {
component: Pill,
tags: ['autodocs', '!dev'],
};
export default meta;

type Story = StoryObj<typeof Pill>;

export const Basic: Story = {
args: {
children: 'Pill',
size: 'sparse',
priority: 'primary',
},
};
12 changes: 12 additions & 0 deletions packages/ui/src/Pill/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { Pill } from '.';
import { render } from '@testing-library/react';
import { ThemeProvider } from '../ThemeProvider';

describe('<Pill />', () => {
it('renders its `children`', () => {
const { queryByText } = render(<Pill>Contents</Pill>, { wrapper: ThemeProvider });

expect(queryByText('Contents')).toBeTruthy();
});
});
43 changes: 43 additions & 0 deletions packages/ui/src/Pill/index.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<Root {...asTransientProps({ size, priority })}>{children}</Root>
);
111 changes: 111 additions & 0 deletions packages/ui/src/ValueViewComponent/AssetIcon/DelegationTokenIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Svg>
<defs>
<radialGradient
id='logoGradient'
cx='-475.62'
cy='477.46'
fx='-475.62'
fy='477.46'
r='1'
gradientTransform='translate(8030.1 3047.46) rotate(-24.39) scale(12.71 -12.71)'
gradientUnits='userSpaceOnUse'
>
<stop offset='0' stopColor='#f79036' />
<stop offset='.6' stopColor='#f79036' />
<stop offset='.88' stopColor='#96d5d1' />
<stop offset='1' stopColor='#96d5d1' />
</radialGradient>
</defs>
<rect id='background' width='32' height='32' fill='#000' strokeWidth='0' />
<path
id='logo'
d='M21.04,4.16c-.87.31-1.73.71-2.55,1.09h0c-1.51.7-2.94,1.36-4.07,1.29h0c-.4-.02-.88-.08-1.44-.16h0c-2.12-.26-5.02-.63-6.54.61h0c-1.08.88-.91,2.97-.75,5h0c.12,1.52.25,3.08-.17,4.02h0c-.94,2.13-1.26,3.94-.95,5.39h0c.3,1.42,1.22,2.54,2.71,3.31h0c.84.44,1.62.62,2.44.81h0c1.2.28,2.44.58,3.99,1.71h0c.77.57,1.57.85,2.42.85h0c1.61,0,3.4-1.04,5.53-3.16h0c.77-.77,1.72-1.22,2.63-1.66h0c1.07-.51,2.18-1.04,3.03-2.09h0c.52-.64.65-1.33.4-2.09h0c-.22-.68-.7-1.36-1.21-2.08h0c-.56-.79-1.13-1.6-1.48-2.53h0c-.6-1.62-.49-2.26-.29-3.44h0c.13-.76.3-1.71.33-3.25h0c.04-1.76-.45-2.76-1.26-3.34h0c-.43-.31-.93-.51-1.55-.51h0c-.37,0-.78.07-1.24.24M11.78,21.76c-1.18-.87-2.01-2.07-2.39-3.47h0c-.46-1.67-.23-3.41.65-4.9h0c.88-1.5,2.3-2.57,4.01-3.02h0c.56-.15,1.14-.23,1.72-.23h0c1.42,0,2.84.47,3.98,1.31h0c1.18.87,2.01,2.07,2.39,3.47h0c.46,1.67.23,3.41-.65,4.9h0c-.88,1.5-2.3,2.57-4.01,3.02h0c-.57.15-1.15.23-1.72.23h0c-1.42,0-2.84-.47-3.98-1.31'
fill='url(#logoGradient)'
opacity='.4'
strokeWidth='0'
/>
<text
id='id'
transform='translate(4.8 13.83)'
fill='#96d5d1'
fontFamily="Iosevka-Term, 'Iosevka Term'"
fontSize='11.06'
pointerEvents='none'
>
<tspan x='0' y='0'>
{firstFour}
</tspan>
<tspan x='0' y='10'>
{lastFour}
</tspan>
</text>
<g id='arrow'>
<line
x1='20.79'
y1='27.45'
x2='25.92'
y2='27.45'
fill='none'
stroke='#96d5d1'
strokeLinecap='round'
strokeMiterlimit='10'
strokeWidth='.75'
/>
<line
x1='24.1'
y1='25.92'
x2='25.92'
y2='27.45'
fill='none'
stroke='#96d5d1'
strokeLinecap='round'
strokeMiterlimit='10'
strokeWidth='.75'
/>
<line
x1='24.1'
y1='28.98'
x2='25.92'
y2='27.45'
fill='none'
stroke='#96d5d1'
strokeLinecap='round'
strokeMiterlimit='10'
strokeWidth='.75'
/>
</g>
</Svg>
);
};
42 changes: 42 additions & 0 deletions packages/ui/src/ValueViewComponent/AssetIcon/Identicon/generate.ts
Original file line number Diff line number Diff line change
@@ -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(),
};
};
68 changes: 68 additions & 0 deletions packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx
Original file line number Diff line number Diff line change
@@ -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 <IdenticonGradient {...props} />;
}
return <IdenticonSolid {...props} />;
};

const IdenticonGradient = ({ uniqueIdentifier, size = 120 }: IdenticonProps) => {
const gradient = useMemo(() => generateGradient(uniqueIdentifier), [uniqueIdentifier]);
const gradientId = useMemo(() => `gradient-${uniqueIdentifier}`, [uniqueIdentifier]);

return (
<Svg $size={size}>
<g>
<defs>
<linearGradient id={gradientId} x1='0' y1='0' x2='1' y2='1'>
<stop offset='0%' stopColor={gradient.fromColor} />
<stop offset='100%' stopColor={gradient.toColor} />
</linearGradient>
</defs>
<rect fill={`url(#${gradientId})`} x='0' y='0' width={size} height={size} />
</g>
</Svg>
);
};

const IdenticonSolid = ({ uniqueIdentifier, size = 120 }: IdenticonProps) => {
const color = useMemo(() => generateSolidColor(uniqueIdentifier), [uniqueIdentifier]);

return (
<Svg $size={size}>
<rect fill={color.bg} x='0' y='0' width={size} height={size} />
<text
x='50%'
y='50%'
textAnchor='middle'
stroke={color.text}
strokeWidth='1px'
dy='.3em'
className='uppercase'
>
{uniqueIdentifier[0]}
</text>
</Svg>
);
};
Loading

0 comments on commit 9da630e

Please sign in to comment.