Skip to content

Commit

Permalink
feat(ui): #1773, #1774: ValueInput and SwapInput (#1775)
Browse files Browse the repository at this point in the history
* fix: storybook asset metadata

* fix: make tailwind work in storybook, add icon to assetSelector

* feat(ui): #1714: update AssetSelectorTrigger to the latest designs

* feat(ui): #1714: implement `ListItem` nested component

* feat(ui): #1714: finish the implementation of the AssetSelector

* fix(ui): #1714: fix selected state of the AssetSelector

* feat(ui): #1714: separate `AssetSelector` from `AssetSelector.Custom`

* chore: changeset

* refactor(ui): #1714: rename `AssetSelectorValue`, export types

* feat(ui): #1773: add `ValueInput` UI component

* feat(ui): #1773: add `SwapInput` UI component

* docs(ui): #1773: add docs for new components

* chore: changeset

* feat(ui): #1714: implement sticky header and the correct height in the dialog

* fix(ui): #1776: update TextInput and Button styles to match the design

* fix(ui): tests

* fix(ui): #1773: hide the arrows in the number input

* fix(ui): #1714: update default filtering of the balances and fix ListItem styles

---------

Co-authored-by: Atris <[email protected]>
  • Loading branch information
VanishMax and vacekj authored Sep 12, 2024
1 parent b1d4b7d commit de9bd06
Show file tree
Hide file tree
Showing 10 changed files with 437 additions and 26 deletions.
5 changes: 5 additions & 0 deletions .changeset/quick-buckets-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/ui': minor
---

Add `SwapInput` and `ValueInput` UI components
7 changes: 6 additions & 1 deletion packages/ui/src/Button/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { DefaultTheme } from 'styled-components';

describe('getBackgroundColor()', () => {
const theme = {
color: { primary: { main: '#aaa' }, neutral: { main: '#ccc' }, destructive: { main: '#f00' } },
color: {
primary: { main: '#aaa' },
neutral: { main: '#ccc' },
destructive: { main: '#f00' },
other: { tonalFill10: '#ccc' },
},
} as DefaultTheme;

describe('when `priority` is `primary`', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/Button/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const getBackgroundColor = (
return theme.color.primary.main;

case 'default':
return theme.color.neutral.main;
return theme.color.other.tonalFill10;

default:
return theme.color[actionType].main;
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/FormField/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ const Root = styled.label`
`;

const HelperText = styled.div<{ $disabled: boolean }>`
${small}
${small};
color: ${props =>
props.$disabled ? props.theme.color.text.muted : props.theme.color.text.secondary};
`;

const LabelText = styled.div<{ $disabled: boolean }>`
${strong}
${strong};
color: ${props =>
props.$disabled ? props.theme.color.text.muted : props.theme.color.text.primary};
Expand All @@ -35,7 +35,7 @@ export interface FormFieldProps {
* to _also_ set it on the child input component.
*/
disabled?: boolean;
helperText?: string;
helperText?: ReactNode;
/**
* The form control to render for this field, whether a `<TextInput />`,
* `<SegmentedControl />`, etc.
Expand Down
57 changes: 57 additions & 0 deletions packages/ui/src/SwapInput/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/react';

import { SwapInput } from '.';
import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb';
import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
import { useState } from 'react';
import {
OSMO_BALANCE,
OSMO_METADATA,
PENUMBRA2_BALANCE,
PENUMBRA_BALANCE,
PENUMBRA_METADATA,
PIZZA_METADATA,
} from '../utils/bufs';
import { AssetSelectorValue } from '../AssetSelector';

const balanceOptions: BalancesResponse[] = [PENUMBRA_BALANCE, PENUMBRA2_BALANCE, OSMO_BALANCE];
const assetOptions: Metadata[] = [PIZZA_METADATA, PENUMBRA_METADATA, OSMO_METADATA];

const meta: Meta<typeof SwapInput> = {
component: SwapInput,
tags: ['autodocs', '!dev'],
argTypes: {
value: { control: false },
},
};
export default meta;

type Story = StoryObj<typeof SwapInput>;

export const SwapInputBasic: Story = {
args: {
label: 'Swap Input',
dialogTitle: 'Transfer Assets',
assets: assetOptions,
balances: balanceOptions,
placeholder: 'Input value...',
},

render: function Render(props) {
const [value, setValue] = useState<string>('');
const [from, setFrom] = useState<AssetSelectorValue>();
const [to, setTo] = useState<AssetSelectorValue>();

return (
<SwapInput
{...props}
value={value}
onValueChange={setValue}
from={from}
onFromChange={setFrom}
to={to}
onToChange={setTo}
/>
);
},
};
159 changes: 159 additions & 0 deletions packages/ui/src/SwapInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import styled from 'styled-components';
import { ArrowLeftRight } from 'lucide-react';
import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb';
import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
import { getBalanceView } from '@penumbra-zone/getters/balances-response';
import { fromValueView } from '@penumbra-zone/types/amount';
import { TextInput } from '../TextInput';
import { FormField } from '../FormField';
import { WalletBalance } from '../WalletBalance';
import { Button } from '../Button';
import { AssetSelector, AssetSelectorValue } from '../AssetSelector';
import { isBalancesResponse } from '../AssetSelector/shared/helpers.ts';
import { ActionType } from '../utils/ActionType.ts';

const AssetsRow = styled.div`
display: flex;
gap: ${props => props.theme.spacing(1)};
align-items: flex-start;
`;

const AssetColumn = styled.div`
flex-grow: 1;
display: flex;
flex-direction: column;
gap: ${props => props.theme.spacing(1)};
`;

// Extends the height of the text input to match the height of the asset selectors
const HeightExtender = styled.div`
width: 0;
height: ${props => props.theme.spacing(12)};
`;

export interface SwapInputProps {
label: string;
placeholder?: string;

/** Numerical value of the corresponding balance */
value?: string;
onValueChange?: (value: string) => void;

/** The `Metadata` or `BalancesResponse`, from which the swap should be initiated */
from?: AssetSelectorValue;
onFromChange?: (value?: AssetSelectorValue) => void;

/** The `Metadata` or `BalancesResponse`, to which the swap should be made */
to?: AssetSelectorValue;
onToChange?: (value?: AssetSelectorValue) => void;

/**
* An array of `Metadata` – protobuf message types describing the asset:
* its name, symbol, id, icons, and more
*/
assets?: Metadata[];
/**
* An array of `BalancesResponse` – protobuf message types describing the balance of an asset:
* the account containing the asset, the value of this asset and its description (has `Metadata` inside it)
*/
balances?: BalancesResponse[];
dialogTitle?: string;

actionType?: ActionType;
disabled?: boolean;
}

/**
* An input field for swapping assets. It allows the user to select the "from" and "to" assets,
* input the amount to swap, and see the balances of the selected assets.
*/
export const SwapInput = ({
assets,
balances,
label,
placeholder,
from,
onFromChange,
to,
onToChange,
value,
onValueChange,
actionType = 'default',
disabled,
dialogTitle,
}: SwapInputProps) => {
const onFromMax = () => {
if (!isBalancesResponse(from)) {
return;
}

const maxValue = fromValueView(getBalanceView(from));
onValueChange?.(maxValue.toString());
};

const onSwap = () => {
onFromChange?.(to);
onToChange?.(from);
};

return (
<FormField label={label}>
<TextInput
min={0}
type='number'
value={value}
disabled={disabled}
actionType={actionType}
placeholder={placeholder}
onChange={onValueChange}
endAdornment={<HeightExtender />}
/>

<AssetsRow>
<AssetColumn>
<AssetSelector
value={from}
assets={assets}
balances={balances}
actionType={actionType}
disabled={disabled}
dialogTitle={dialogTitle}
onChange={onFromChange}
/>
{isBalancesResponse(from) && (
<WalletBalance
balance={from}
actionType={actionType}
disabled={disabled}
onClick={onFromMax}
/>
)}
</AssetColumn>

<Button
priority='primary'
iconOnly
icon={ArrowLeftRight}
disabled={disabled}
actionType={actionType}
onClick={onSwap}
>
Swap
</Button>

<AssetColumn>
<AssetSelector
value={to}
assets={assets}
balances={balances}
actionType={actionType}
disabled={disabled}
dialogTitle={dialogTitle}
onChange={onToChange}
/>
{isBalancesResponse(to) && <WalletBalance balance={to} actionType={actionType} />}
</AssetColumn>
</AssetsRow>
</FormField>
);
};
57 changes: 37 additions & 20 deletions packages/ui/src/TextInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
import styled, { DefaultTheme } from 'styled-components';
import styled from 'styled-components';
import { small } from '../utils/typography';
import { ActionType } from '../utils/ActionType';
import { ActionType, getOutlineColorByActionType } from '../utils/ActionType';
import { useDisabled } from '../hooks/useDisabled';
import { forwardRef, ReactNode } from 'react';

const BORDER_BOTTOM_WIDTH = '2px';

const borderColorByActionType: Record<ActionType, keyof DefaultTheme['color']['action']> = {
default: 'neutralFocusOutline',
accent: 'primaryFocusOutline',
unshield: 'unshieldFocusOutline',
destructive: 'destructiveFocusOutline',
};

const Wrapper = styled.div<{ $hasStartAdornment: boolean; $hasEndAdornment: boolean }>`
const Wrapper = styled.div<{
$hasStartAdornment: boolean;
$hasEndAdornment: boolean;
$actionType: ActionType;
}>`
background-color: ${props => props.theme.color.other.tonalFill5};
display: flex;
align-items: center;
gap: ${props => props.theme.spacing(2)};
transition:
outline 0.15s,
background-color 0.15s;
${props => props.$hasStartAdornment && `padding-left: ${props.theme.spacing(3)};`}
${props => props.$hasEndAdornment && `padding-right: ${props.theme.spacing(3)};`}
&:focus-within {
outline: 2px solid ${props => getOutlineColorByActionType(props.theme, props.$actionType)};
}
&:hover {
background-color: ${props => props.theme.color.action.hoverOverlay};
}
`;

const StyledInput = styled.input<{
Expand All @@ -37,8 +43,7 @@ const StyledInput = styled.input<{
padding-left: ${props => (props.$hasStartAdornment ? '0' : props.theme.spacing(3))};
padding-right: ${props => (props.$hasEndAdornment ? '0' : props.theme.spacing(3))};
padding-top: ${props => props.theme.spacing(2)};
padding-bottom: calc(${props => props.theme.spacing(2)} - ${BORDER_BOTTOM_WIDTH});
border-bottom: ${BORDER_BOTTOM_WIDTH} solid ${props => props.theme.color.base.transparent};
padding-bottom: ${props => props.theme.spacing(2)};
transition: border-color 0.15s;
box-sizing: border-box;
Expand All @@ -59,15 +64,23 @@ const StyledInput = styled.input<{
}
&:focus {
border-bottom-color: ${props =>
props.theme.color.action[borderColorByActionType[props.$actionType]]};
outline: none;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&[type='number'] {
-moz-appearance: textfield;
}
`;

export interface TextInputProps {
value: string;
onChange: (value: string) => void;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
actionType?: ActionType;
disabled?: boolean;
Expand Down Expand Up @@ -109,12 +122,16 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
}: TextInputProps,
ref,
) => (
<Wrapper $hasStartAdornment={!!startAdornment} $hasEndAdornment={!!endAdornment}>
<Wrapper
$actionType={actionType}
$hasStartAdornment={!!startAdornment}
$hasEndAdornment={!!endAdornment}
>
{startAdornment}

<StyledInput
value={value}
onChange={e => onChange(e.target.value)}
onChange={e => onChange?.(e.target.value)}
placeholder={placeholder}
disabled={useDisabled(disabled)}
type={type}
Expand Down
Loading

0 comments on commit de9bd06

Please sign in to comment.