diff --git a/.changeset/happy-mayflies-destroy.md b/.changeset/happy-mayflies-destroy.md new file mode 100644 index 0000000000..99c81e29ed --- /dev/null +++ b/.changeset/happy-mayflies-destroy.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/ui': minor +--- + +Add `AssetSelector` UI component diff --git a/packages/ui/.storybook/main.js b/packages/ui/.storybook/main.js index d02b662a7d..7870d3cbce 100644 --- a/packages/ui/.storybook/main.js +++ b/packages/ui/.storybook/main.js @@ -32,7 +32,15 @@ const config = { getAbsolutePath('@storybook/addon-links'), getAbsolutePath('@storybook/addon-essentials'), getAbsolutePath('@storybook/addon-interactions'), - '@storybook/addon-postcss', + { + name: '@storybook/addon-postcss', + options: { + postcssLoaderOptions: { + // When using postCSS 8 + implementation: require('postcss'), + }, + }, + }, '@storybook/preview-api', ], framework: { diff --git a/packages/ui/.storybook/preview.jsx b/packages/ui/.storybook/preview.jsx index f000fcc922..3677658652 100644 --- a/packages/ui/.storybook/preview.jsx +++ b/packages/ui/.storybook/preview.jsx @@ -6,6 +6,7 @@ import { PenumbraUIProvider } from '../src/PenumbraUIProvider'; import { Density } from '../src/Density'; import { Tabs } from '../src/Tabs'; import styled from 'styled-components'; +import '../styles/globals.css'; const Column = styled.div` display: flex; diff --git a/packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/Balance.tsx b/packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/Balance.tsx deleted file mode 100644 index 55129b7a97..0000000000 --- a/packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/Balance.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; -import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; -import styled from 'styled-components'; -import { Text } from '../../../Text'; -import { getAddressIndex, getBalanceView } from '@penumbra-zone/getters/balances-response'; - -const Root = styled.div` - display: flex; - flex-direction: column; - align-items: flex-end; -`; - -export interface BalanceProps { - balancesResponse: BalancesResponse; -} - -export const Balance = ({ balancesResponse }: BalanceProps) => { - const addressIndexAccount = getAddressIndex.optional(balancesResponse)?.account; - const valueView = getBalanceView.optional(balancesResponse); - return ( - - {valueView && {getFormattedAmtFromValueView(valueView, true)}} - - {addressIndexAccount !== undefined && ( - color.text.secondary}> - Account #{addressIndexAccount} - - )} - - ); -}; diff --git a/packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/index.tsx b/packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/index.tsx deleted file mode 100644 index 007c647fc4..0000000000 --- a/packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; -import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; -import { AnimationPlaybackControls, motion, useAnimate } from 'framer-motion'; -import styled from 'styled-components'; -import { buttonBase } from '../../../utils/button'; -import { isBalancesResponse, isMetadata } from '../../helpers'; -import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; -import { AssetIcon } from '../../../AssetIcon'; -import { Text } from '../../../Text'; -import { Balance } from './Balance'; -import { useIsAnimating } from '../../../hooks/useIsAnimating'; -import { useEffect, useRef } from 'react'; - -const Root = styled(motion.button)<{ $isSelected: boolean }>` - ${buttonBase} - - border-radius: ${props => props.theme.borderRadius.sm}; - background-color: ${props => props.theme.color.other.tonalFill10}; - padding: ${props => props.theme.spacing(3)}; - - display: flex; - justify-content: space-between; - align-items: center; - - margin: ${props => (props.$isSelected ? props.theme.spacing(3) : 0)} 0; - - text-align: left; -`; - -const AssetIconAndName = styled.div` - display: flex; - gap: ${props => props.theme.spacing(2)}; - align-items: center; -`; - -export interface MetadataOrBalancesResponseProps { - value: Metadata | BalancesResponse; - isSelected: boolean; - onSelect: VoidFunction; -} - -export const MetadataOrBalancesResponse = ({ - value, - isSelected, - onSelect, -}: MetadataOrBalancesResponseProps) => { - const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse.optional(value); - const isParentAnimating = useIsAnimating(); - const [scope, animate] = useAnimate(); - const animationControls = useRef(); - - /** - * We delay the animation of making the metadata/balances response appear - * until the parent is finished animating. Otherwise, these will transition in - * weirdly, since the `layout` prop is applied to `Root`. - * - * @todo: Find a more elegant solution for waiting for a parent layout - * animation to finish before starting a child animation. Framer Motion has - * solutions for orchestration - * (https://www.framer.com/motion/animation/##orchestration), but they don't - * seem to work with shared layout animations. - */ - useEffect(() => { - if (isParentAnimating) { - animationControls.current?.cancel(); - animationControls.current = animate(scope.current, { opacity: 0 }); - } else { - animationControls.current?.cancel(); - animationControls.current = animate(scope.current, { opacity: 1 }); - } - }, [animate, isParentAnimating, scope]); - - return ( - - - -
- {metadata?.name && {metadata.name}} - {metadata?.symbol && ( - color.text.secondary} as='div'> - {metadata.symbol} - - )} -
-
- - {isBalancesResponse(value) && } -
- ); -}; diff --git a/packages/ui/src/AssetSelector/AssetSelectorDialogContent/index.tsx b/packages/ui/src/AssetSelector/AssetSelectorDialogContent/index.tsx deleted file mode 100644 index 6acc3364e3..0000000000 --- a/packages/ui/src/AssetSelector/AssetSelectorDialogContent/index.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; -import { Dialog } from '../../Dialog'; -import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; -import { MetadataOrBalancesResponse } from './MetadataOrBalancesResponse'; -import { isBalancesResponse, isMetadata } from '../helpers'; -import { getAssetId } from '@penumbra-zone/getters/metadata'; -import { bech32mAssetId } from '@penumbra-zone/bech32m/passet'; -import { - getAddressIndex, - getAssetIdFromBalancesResponse, -} from '@penumbra-zone/getters/balances-response'; -import styled from 'styled-components'; -import { TextInput } from '../../TextInput'; -import { Icon } from '../../Icon'; -import { Search } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import { filterMetadataOrBalancesResponseByText } from '../filterMetadataOrBalancesResponseByText'; -import { IsAnimatingProvider } from '../../IsAnimatingProvider'; - -const isEqual = ( - value1: BalancesResponse | Metadata, - value2: BalancesResponse | Metadata | undefined, -) => { - if (isMetadata(value1)) { - return isMetadata(value2) && value1.equals(value2); - } - - return isBalancesResponse(value2) && value1.equals(value2); -}; - -const getKey = (option: BalancesResponse | Metadata): string => { - if (isMetadata(option)) { - return bech32mAssetId(getAssetId(option)); - } - - const assetId = getAssetIdFromBalancesResponse(option); - const addressIndexAccount = getAddressIndex(option).account; - - return `${addressIndexAccount}.${bech32mAssetId(assetId)}`; -}; - -const OptionsWrapper = styled.div` - display: flex; - flex-direction: column; - gap: ${props => props.theme.spacing(1)}; -`; - -export interface AssetSelectorDialogContentProps< - ValueType extends (BalancesResponse | Metadata) | Metadata, -> { - title: string; - layoutId: string; - value?: ValueType; - onChange: (value: ValueType) => void; - options: ValueType[]; -} - -export const AssetSelectorDialogContent = < - ValueType extends (BalancesResponse | Metadata) | Metadata, ->({ - title, - layoutId, - value, - onChange, - options, -}: AssetSelectorDialogContentProps) => { - const [search, setSearch] = useState(''); - const filteredOptions = useMemo( - () => options.filter(filterMetadataOrBalancesResponseByText(search)), - [search, options], - ); - - return ( - - {props => ( - - color.text.primary} /> - } - value={search} - onChange={setSearch} - placeholder='Search...' - /> - - - {filteredOptions.map(option => ( - onChange(option)} - /> - ))} - - - )} - - ); -}; diff --git a/packages/ui/src/AssetSelector/Custom.tsx b/packages/ui/src/AssetSelector/Custom.tsx new file mode 100644 index 0000000000..0735f42689 --- /dev/null +++ b/packages/ui/src/AssetSelector/Custom.tsx @@ -0,0 +1,141 @@ +import { ReactNode, useId, useState } from 'react'; +import styled from 'styled-components'; +import { RadioGroup } from '@radix-ui/react-radio-group'; +import { Dialog } from '../Dialog'; +import { IsAnimatingProvider } from '../IsAnimatingProvider'; +import { getHash } from './shared/helpers.ts'; +import { AssetSelectorContext } from './shared/Context.tsx'; +import { AssetSelectorSearchFilter } from './SearchFilter.tsx'; +import { AssetSelectorTrigger } from './Trigger.tsx'; +import { AssetSelectorBaseProps } from './shared/types.ts'; + +const OptionsWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${props => props.theme.spacing(1)}; +`; + +interface ChildrenArguments { + onClose: VoidFunction; + /** + * Takes the `Metadata` or `BalancesResponse` and returns + * a unique key string to be used within map in React + */ + getKeyHash: typeof getHash; +} + +export interface AssetSelectorCustomProps extends AssetSelectorBaseProps { + /** A value of the search filter inside the selector dialog */ + search?: string; + + /** Fires when user inputs the value into the search filter inside the selector dialog */ + onSearchChange?: (newValue: string) => void; + + /** + * Use children as a function to get assistance with keying + * the `ListItem`s and implement you own closing logic. + * + * Example: + * ```tsx + * + * {({ getKeyHash, onClose }) => ( + * <> + * {options.map(option => ( + * + * ))} + * + * + * )} + * + * ``` + * */ + children?: ReactNode | ((args: ChildrenArguments) => ReactNode); +} + +/** + * A custom version of the `AssetSelector` that lets you customize the contents of the selector dialog. + * + * Use `AssetSelector.ListItem` inside the `AssetSelector.Custom` to render the options + * of the selector. It is up for you to sort or group the options however you want. + * + * Example usage: + * + * ```tsx + * const [value, setValue] = useState(); + * const [search, setSearch] = useState(''); + * + * const filteredOptions = useMemo( + * () => mixedOptions.filter(filterMetadataOrBalancesResponseByText(search)), + * [search], + * ); + * + * return ( + * + * {({ getKeyHash }) => + * filteredOptions.map(option => ( + * + * )) + * } + * + * ); + * ``` + */ +export const AssetSelectorCustom = ({ + value, + onChange, + dialogTitle = 'Select Asset', + actionType, + disabled, + children, + search, + onSearchChange, +}: AssetSelectorCustomProps) => { + const layoutId = useId(); + + const [isOpen, setIsOpen] = useState(false); + + const onClose = () => setIsOpen(false); + + return ( + setIsOpen(false)}> + + setIsOpen(true)} + /> + + + {props => ( + + ) + } + > + + + {typeof children === 'function' + ? children({ onClose, getKeyHash: getHash }) + : children} + + + + )} + + + + ); +}; diff --git a/packages/ui/src/AssetSelector/ListItem.tsx b/packages/ui/src/AssetSelector/ListItem.tsx new file mode 100644 index 0000000000..9d94ba325d --- /dev/null +++ b/packages/ui/src/AssetSelector/ListItem.tsx @@ -0,0 +1,188 @@ +import { RadioGroupItem } from '@radix-ui/react-radio-group'; +import styled from 'styled-components'; +import { motion } from 'framer-motion'; +import { AssetIcon } from '../AssetIcon'; +import { Text } from '../Text'; +import { getHash, isBalancesResponse, isMetadata } from './shared/helpers.ts'; +import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; +import { + getAddressIndex, + getBalanceView, + getMetadataFromBalancesResponse, +} from '@penumbra-zone/getters/balances-response'; +import { ActionType, getOutlineColorByActionType } from '../utils/ActionType.ts'; +import { asTransientProps } from '../utils/asTransientProps.ts'; +import { KeyboardEventHandler, MouseEventHandler } from 'react'; +import { useAssetsSelector } from './shared/Context.tsx'; +import { AssetSelectorValue } from './shared/types.ts'; +import { media } from '../utils/media.ts'; + +const Root = styled(motion.button)<{ + $isSelected: boolean; + $actionType: ActionType; + $disabled?: boolean; +}>` + border-radius: ${props => props.theme.borderRadius.sm}; + background-color: ${props => props.theme.color.other.tonalFill5}; + padding: ${props => props.theme.spacing(3)}; + + display: flex; + justify-content: space-between; + align-items: center; + text-align: left; + transition: + background 0.15s, + outline 0.15s; + + &:hover { + background: linear-gradient( + 0deg, + ${props => props.theme.color.action.hoverOverlay} 0%, + ${props => props.theme.color.action.hoverOverlay} 100% + ), + ${props => props.theme.color.other.tonalFill5}; + } + + &:focus { + background: linear-gradient( + 0deg, + ${props => props.theme.color.action.hoverOverlay} 0%, + ${props => props.theme.color.action.hoverOverlay} 100% + ), + ${props => props.theme.color.other.tonalFill5}; + outline: 2px solid ${props => getOutlineColorByActionType(props.theme, props.$actionType)}; + } + + &[aria-checked='true'] { + outline: 2px solid ${props => getOutlineColorByActionType(props.theme, props.$actionType)}; + } + + &:disabled { + background: linear-gradient( + 0deg, + ${props => props.theme.color.action.disabledOverlay} 0%, + ${props => props.theme.color.action.disabledOverlay} 100% + ), + ${props => props.theme.color.other.tonalFill5}; + } +`; + +const AssetInfo = styled.div` + display: flex; + gap: ${props => props.theme.spacing(2)}; + align-items: center; +`; + +const AssetTitle = styled.div` + display: flex; + align-items: center; + white-space: nowrap; + gap: ${props => props.theme.spacing(1)}; +`; + +const AssetTitleText = styled(Text)` + display: inline-block; + max-width: 100px; + + ${media.tablet` + max-width: 300px; + `} + + ${media.lg` + max-width: 400px; + `} +`; + +const Balance = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; +`; + +export interface ListItemProps { + /** + * A `BalancesResponse` or `Metadata` protobuf message type. Renders the asset + * icon name and, depending on the type, the value of the asset in the account. + * */ + value: AssetSelectorValue; + disabled?: boolean; + actionType?: ActionType; +} + +/** A radio button that selects an asset or a balance from the `AssetSelector` */ +export const ListItem = ({ value, disabled, actionType = 'default' }: ListItemProps) => { + const { onClose, onChange, value: selectedValue } = useAssetsSelector(); + + const hash = getHash(value); + const isSelected = !!selectedValue && getHash(value) === getHash(selectedValue); + + const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse.optional(value); + + const balance = isBalancesResponse(value) + ? { + addressIndexAccount: getAddressIndex.optional(value)?.account, + valueView: getBalanceView.optional(value), + } + : undefined; + + const onEnter: KeyboardEventHandler = event => { + if (event.key === 'Enter') { + onClose(); + } + }; + + const onMouseDown: MouseEventHandler = () => { + // close only after the value is selected by onClick + setTimeout(() => { + onClose(); + }, 0); + }; + + // click is triggered by radix-ui on focus, click, arrow selection, etc. – basically always + const onClick = () => { + onChange?.(value); + }; + + return ( + + + + +
+ + {balance?.valueView && ( + + {getFormattedAmtFromValueView(balance.valueView, true)}{' '} + + )} + + {metadata?.symbol ?? 'Unknown'} + + + {metadata?.name && ( + color.text.secondary} as='div'> + {metadata.name} + + )} +
+
+ + {balance?.addressIndexAccount !== undefined && ( + + color.text.secondary}> + #{balance.addressIndexAccount} + + color.text.secondary}> + Account + + + )} +
+
+ ); +}; diff --git a/packages/ui/src/AssetSelector/SearchFilter.tsx b/packages/ui/src/AssetSelector/SearchFilter.tsx new file mode 100644 index 0000000000..3d79e60b43 --- /dev/null +++ b/packages/ui/src/AssetSelector/SearchFilter.tsx @@ -0,0 +1,21 @@ +import { Search } from 'lucide-react'; +import { Icon } from '../Icon'; +import { TextInput } from '../TextInput'; + +export interface AssetSelectorSearchFilterProps { + value?: string; + onChange?: (newValue: string) => void; +} + +export const AssetSelectorSearchFilter = ({ value, onChange }: AssetSelectorSearchFilterProps) => { + const handleSearch = (newValue: string) => onChange?.(newValue); + + return ( + color.text.primary} />} + value={value ?? ''} + onChange={handleSearch} + placeholder='Search...' + /> + ); +}; diff --git a/packages/ui/src/AssetSelector/Trigger.tsx b/packages/ui/src/AssetSelector/Trigger.tsx new file mode 100644 index 0000000000..033d1dbecf --- /dev/null +++ b/packages/ui/src/AssetSelector/Trigger.tsx @@ -0,0 +1,127 @@ +import { forwardRef, MouseEventHandler } from 'react'; +import styled, { css } from 'styled-components'; +import { ChevronsUpDownIcon } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; +import { ActionType, getOutlineColorByActionType } from '../utils/ActionType.ts'; +import { Density } from '../types/Density.ts'; +import { useDensity } from '../hooks/useDensity'; +import { asTransientProps } from '../utils/asTransientProps.ts'; +import { Icon } from '../Icon'; +import { Text } from '../Text'; +import { AssetIcon } from '../AssetIcon'; +import { isMetadata } from './shared/helpers.ts'; +import { Dialog } from '../Dialog/index.tsx'; +import { AssetSelectorValue } from './shared/types.ts'; + +const SparseButton = css` + height: ${props => props.theme.spacing(12)}; + padding: 0 ${props => props.theme.spacing(3)}; +`; + +const CompactButton = css` + height: ${props => props.theme.spacing(8)}; + padding: 0 ${props => props.theme.spacing(2)}; +`; + +const Trigger = styled(motion.button)<{ $density: Density; $actionType: ActionType }>` + display: flex; + justify-content: space-between; + align-items: center; + gap: ${props => props.theme.spacing(1)}; + min-width: ${props => props.theme.spacing(20)}; + border-radius: ${props => props.theme.borderRadius.none}; + background: ${props => props.theme.color.other.tonalFill5}; + transition: + background 0.15s, + outline 0.15s; + + ${props => (props.$density === 'sparse' ? SparseButton : CompactButton)}; + + &:hover { + background-color: ${props => props.theme.color.action.hoverOverlay}; + } + + &:focus { + color: ${props => props.theme.color.text.secondary}; + background: ${props => props.theme.color.other.tonalFill5}; + outline: 2px solid ${props => getOutlineColorByActionType(props.theme, props.$actionType)}; + } + + &:disabled { + background: linear-gradient( + 0deg, + ${props => props.theme.color.action.disabledOverlay} 0%, + ${props => props.theme.color.action.disabledOverlay} 100% + ), + ${props => props.theme.color.other.tonalFill10}; + } +`; + +const Value = styled.div<{ $density: Density; $actionType: ActionType }>` + display: flex; + align-items: center; + gap: ${props => props.theme.spacing(props.$density === 'sparse' ? 2 : 1)}; +`; + +const IconAdornment = styled.i<{ $disabled?: boolean }>` + display: flex; + align-items: center; + justify-content: center; + padding: ${props => props.theme.spacing(1)}; + width: ${props => props.theme.spacing(6)}; + height: ${props => props.theme.spacing(6)}; + border-radius: ${props => props.theme.borderRadius.full}; + background-color: ${props => + props.$disabled ? props.theme.color.action.disabledOverlay : 'transparent'}; +`; + +export interface AssetSelectorTriggerProps { + value?: AssetSelectorValue; + actionType?: ActionType; + disabled?: boolean; + onClick?: MouseEventHandler; + layoutId?: string; +} + +export const AssetSelectorTrigger = forwardRef( + ({ value, actionType = 'default', disabled, onClick, layoutId }, ref) => { + const density = useDensity(); + + const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse.optional(value); + + return ( + + + {!value ? ( + (disabled ? color.text.muted : color.text.primary)}> + Asset + + ) : ( + + + (disabled ? color.text.muted : color.text.primary)}> + {metadata?.symbol ?? 'Unknown'} + + + )} + + + (disabled ? color.text.muted : color.text.primary)} + /> + + + + ); + }, +); +AssetSelectorTrigger.displayName = 'AssetSelectorTrigger'; diff --git a/packages/ui/src/AssetSelector/helpers.ts b/packages/ui/src/AssetSelector/helpers.ts deleted file mode 100644 index f99f904b4e..0000000000 --- a/packages/ui/src/AssetSelector/helpers.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; -import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; - -/** Type predicate to check if a value is a `Metadata`. */ -export const isMetadata = (value?: Metadata | BalancesResponse): value is Metadata => - value?.getType() === Metadata; - -/** Type predicate to check if a value is a `BalancesResponse`. */ -export const isBalancesResponse = ( - value?: Metadata | BalancesResponse, -): value is BalancesResponse => value?.getType() === BalancesResponse; diff --git a/packages/ui/src/AssetSelector/index.stories.tsx b/packages/ui/src/AssetSelector/index.stories.tsx index 4210d81e54..f11608b0e2 100644 --- a/packages/ui/src/AssetSelector/index.stories.tsx +++ b/packages/ui/src/AssetSelector/index.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { useArgs } from '@storybook/preview-api'; -import { AssetSelector } from '.'; +import { AssetSelector, AssetSelectorValue } 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'; @@ -14,20 +13,14 @@ import { PIZZA_METADATA, } from '../utils/bufs'; -const mixedOptions: (BalancesResponse | Metadata)[] = [ - PIZZA_METADATA, - PENUMBRA_BALANCE, - PENUMBRA2_BALANCE, - OSMO_BALANCE, -]; -const metadataOnlyOptions: Metadata[] = [PIZZA_METADATA, PENUMBRA_METADATA, OSMO_METADATA]; +const balanceOptions: BalancesResponse[] = [PENUMBRA_BALANCE, PENUMBRA2_BALANCE, OSMO_BALANCE]; +const assetOptions: Metadata[] = [PIZZA_METADATA, PENUMBRA_METADATA, OSMO_METADATA]; const meta: Meta = { component: AssetSelector, tags: ['autodocs', '!dev', 'density'], argTypes: { value: { control: false }, - options: { control: false }, }, }; export default meta; @@ -37,30 +30,13 @@ type Story = StoryObj; export const MixedBalancesResponsesAndMetadata: Story = { args: { dialogTitle: 'Transfer Assets', - value: PENUMBRA_BALANCE, - options: mixedOptions, + assets: assetOptions, + balances: balanceOptions, }, render: function Render(props) { - const [, updateArgs] = useArgs(); - - const onChange = (value: BalancesResponse | Metadata) => updateArgs({ value }); - - return ; - }, -}; - -export const MetadataOnly: Story = { - render: function Render() { - const [value, setValue] = useState(PENUMBRA_METADATA); + const [value, setValue] = useState(); - return ( - - ); + return ; }, }; diff --git a/packages/ui/src/AssetSelector/index.tsx b/packages/ui/src/AssetSelector/index.tsx index 76e7efc658..23c0e2e8d2 100644 --- a/packages/ui/src/AssetSelector/index.tsx +++ b/packages/ui/src/AssetSelector/index.tsx @@ -1,108 +1,162 @@ +import { useMemo, useState } from 'react'; +import styled from 'styled-components'; import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; -import { Dialog } from '../Dialog'; -import { Text } from '../Text'; -import styled from 'styled-components'; -import { buttonBase } from '../utils/button'; -import { Density } from '../types/Density'; -import { useDensity } from '../hooks/useDensity'; -import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; -import { AssetIcon } from '../AssetIcon'; -import { ConditionalWrap } from '../ConditionalWrap'; -import { AssetSelectorDialogContent } from './AssetSelectorDialogContent'; -import { motion } from 'framer-motion'; -import { useId, useState } from 'react'; -import { isMetadata } from './helpers'; -const Button = styled(motion.button)<{ $density: Density }>` - ${buttonBase} +import { isBalancesResponse, isMetadata } from './shared/helpers.ts'; +import { filterMetadataOrBalancesResponseByText } from './shared/filterMetadataOrBalancesResponseByText.ts'; +import { AssetSelectorBaseProps, AssetSelectorValue } from './shared/types.ts'; +import { AssetSelectorCustom, AssetSelectorCustomProps } from './Custom.tsx'; +import { ListItem, ListItemProps } from './ListItem.tsx'; +import { Text } from '../Text'; +import { groupAndSort } from './shared/groupAndSort.ts'; - background-color: ${props => props.theme.color.other.tonalFill5}; - height: ${props => props.theme.spacing(props.$density === 'sparse' ? 12 : 8)}; - text-align: left; - padding: 0 ${props => props.theme.spacing(props.$density === 'sparse' ? 3 : 2)}; - width: ${props => (props.$density === 'sparse' ? '100%' : 'max-content')}; +const ListItemGroup = styled.div` + display: flex; + flex-direction: column; + gap: ${props => props.theme.spacing(1)}; `; -const Row = styled.div<{ $density: Density }>` +const SelectorList = styled.div` display: flex; - gap: ${props => props.theme.spacing(props.$density === 'sparse' ? 2 : 1)}; - align-items: center; + flex-direction: column; + gap: ${props => props.theme.spacing(4)}; `; -export interface AssetSelectorProps { +export interface AssetSelectorProps extends AssetSelectorBaseProps { /** - * The currently selected `Metadata` or `BalancesResponse`. + * An array of `Metadata` – protobuf message types describing the asset: + * its name, symbol, id, icons, and more */ - value?: ValueType; - onChange: (value: ValueType) => void; + assets?: Metadata[]; /** - * An array of `Metadata`s and possibly `BalancesResponse`s to render as - * options. If `BalancesResponse`s are included in the `options` array, those - * options will be rendered with the user's balance of them. + * 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) */ - options: ValueType[]; - /** The title to show above the asset selector dialog when it opens. */ - dialogTitle: string; + balances?: BalancesResponse[]; } /** * Allows users to choose an asset for e.g., the swap and send forms. Note that - * the `options` prop can be an array of just `Metadata`s, or a mixed array of + * it can render an array of just `Metadata`s, or a mixed array of * both `Metadata`s and `BalancesResponse`s. The latter is useful for e.g., * letting the user estimate a swap of an asset they don't hold. + * + * The component has two ways of using it: + * + * ### 1. + * + * A default way with pre-defined grouping, sorting, searching and rendering algorithms. Renders the list of balances on top of the dialog with account index grouping and priority sorting within each group. When searching, it filters the assets by name, symbol, display name and base name. + * + * Example: + * + * ```tsx + * const [value, setValue] = useState(); + * + * + * ``` + * + * ### 2. + * + * A custom way. You can use the `AssetSelector.Custom` with `AssetSelector.ListItem` to render the options of the selector. It is up to the consumer to sort or group the options however they want. + * + * Example: + * + * ```tsx + * const [value, setValue] = useState(); + * const [search, setSearch] = useState(''); + * + * const filteredOptions = useMemo( + * () => mixedOptions.filter(filterMetadataOrBalancesResponseByText(search)), + * [search], + * ); + * + * return ( + * + * {({ getKeyHash }) => + * filteredOptions.map(option => ( + * + * )) + * } + * + * ); + * ``` */ -export const AssetSelector = ({ +export const AssetSelector = ({ + assets = [], + balances = [], value, onChange, - options, - dialogTitle, -}: AssetSelectorProps) => { - const layoutId = useId(); - const density = useDensity(); - const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse.optional(value); - - const [isOpen, setIsOpen] = useState(false); + dialogTitle = 'Select Asset', + actionType, + disabled, +}: AssetSelectorProps) => { + const [search, setSearch] = useState(''); - const handleChange = (newValue: ValueType) => { - onChange(newValue); - setIsOpen(false); - }; + const { filteredAssets, filteredBalances } = useMemo( + () => ({ + filteredAssets: assets.filter(filterMetadataOrBalancesResponseByText(search)), + filteredBalances: groupAndSort( + balances.filter(filterMetadataOrBalancesResponseByText(search)), + ), + }), + [assets, balances, search], + ); return ( - setIsOpen(false)}> - + {!!filteredAssets.length && ( + color.text.secondary}> + All Tokens + + )} - - + {filteredAssets.map(asset => ( + + ))} + + )} + ); }; + +AssetSelector.Custom = AssetSelectorCustom; +AssetSelector.ListItem = ListItem; + +export { isBalancesResponse, isMetadata }; + +export type { AssetSelectorValue, AssetSelectorCustomProps, ListItemProps }; diff --git a/packages/ui/src/AssetSelector/shared/Context.tsx b/packages/ui/src/AssetSelector/shared/Context.tsx new file mode 100644 index 0000000000..43255a1d22 --- /dev/null +++ b/packages/ui/src/AssetSelector/shared/Context.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; +import { AssetSelectorValue } from './types.ts'; + +export interface AssetSelectorContextValue { + onClose: VoidFunction; + onChange?: (value: AssetSelectorValue) => void; + value: AssetSelectorValue | undefined; +} + +/** + * Provides helper functions to be consumed from `ListItem` component, only for inner usage. + * These components must be rendered by the user to provide custom sorting or grouping but + * the selection logic is standardized by this context. + */ +export const AssetSelectorContext = createContext( + {} as AssetSelectorContextValue, +); + +export const useAssetsSelector = () => useContext(AssetSelectorContext); diff --git a/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.test.ts b/packages/ui/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.test.ts similarity index 95% rename from packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.test.ts rename to packages/ui/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.test.ts index e3963ecf94..b6e2314b21 100644 --- a/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.test.ts +++ b/packages/ui/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { filterMetadataOrBalancesResponseByText } from './filterMetadataOrBalancesResponseByText'; -import { PENUMBRA_BALANCE, PENUMBRA_METADATA } from '../utils/bufs'; +import { filterMetadataOrBalancesResponseByText } from './filterMetadataOrBalancesResponseByText.ts'; +import { PENUMBRA_BALANCE, PENUMBRA_METADATA } from '../../utils/bufs'; describe('filterMetadataOrBalancesResponseByText()', () => { describe('when the search text is empty', () => { diff --git a/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.ts b/packages/ui/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.ts similarity index 95% rename from packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.ts rename to packages/ui/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.ts index e92944cce9..7e5b9599dc 100644 --- a/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.ts +++ b/packages/ui/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.ts @@ -1,6 +1,6 @@ import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; -import { isMetadata } from './helpers'; +import { isMetadata } from './helpers.ts'; import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; export const filterMetadataOrBalancesResponseByText = diff --git a/packages/ui/src/AssetSelector/shared/groupAndSort.ts b/packages/ui/src/AssetSelector/shared/groupAndSort.ts new file mode 100644 index 0000000000..07483e0367 --- /dev/null +++ b/packages/ui/src/AssetSelector/shared/groupAndSort.ts @@ -0,0 +1,81 @@ +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { + getAmount, + getMetadataFromBalancesResponse, + getAddressIndex, + getValueViewCaseFromBalancesResponse, +} from '@penumbra-zone/getters/balances-response'; +import { joinLoHiAmount, multiplyAmountByNumber } from '@penumbra-zone/types/amount'; +import { assetPatterns } from '@penumbra-zone/types/assets'; +import { getDisplay } from '@penumbra-zone/getters/metadata'; + +const nonSwappableAssetPatterns = [ + assetPatterns.lpNft, + assetPatterns.proposalNft, + assetPatterns.votingReceipt, + assetPatterns.auctionNft, + assetPatterns.lpNft, + + // In theory, these asset types are swappable, but we have removed them for now to get a better UX + assetPatterns.delegationToken, + assetPatterns.unbondingToken, +]; + +const isUnswappable = (balance: BalancesResponse): boolean => { + const metadata = getMetadataFromBalancesResponse.optional(balance); + if (!metadata) { + return true; + } + return nonSwappableAssetPatterns.some(pattern => pattern.matches(getDisplay(metadata))); +}; + +const isUnknownBalance = (balance: BalancesResponse): boolean => { + return getValueViewCaseFromBalancesResponse.optional(balance) !== 'knownAssetId'; +}; + +const groupByAccount = ( + acc: Record, + curr: BalancesResponse, +): Record => { + const index = getAddressIndex.optional(curr)?.account; + + if (index === undefined || isUnknownBalance(curr) || isUnswappable(curr)) { + return acc; + } + + if (acc[index]) { + acc[index].push(curr); + } else { + acc[index] = [curr]; + } + + return acc; +}; + +const sortByAccountIndex = (a: [string, BalancesResponse[]], b: [string, BalancesResponse[]]) => { + return Number(a[0]) - Number(b[0]); +}; + +const sortbyPriorityScore = (a: BalancesResponse, b: BalancesResponse) => { + const aScore = getMetadataFromBalancesResponse.optional(a)?.priorityScore ?? 1n; + const bScore = getMetadataFromBalancesResponse.optional(b)?.priorityScore ?? 1n; + + const aAmount = getAmount.optional(a); + const bAmount = getAmount.optional(b); + + const aPriority = aAmount + ? joinLoHiAmount(multiplyAmountByNumber(aAmount, Number(aScore))) + : aScore; + const bPriority = bAmount + ? joinLoHiAmount(multiplyAmountByNumber(bAmount, Number(bScore))) + : bScore; + + return Number(bPriority - aPriority); +}; + +export const groupAndSort = (balances: BalancesResponse[]): [string, BalancesResponse[]][] => { + const grouped = balances.reduce(groupByAccount, {}); + return Object.entries(grouped) + .sort(sortByAccountIndex) + .map(([index, balances]) => [index, balances.sort(sortbyPriorityScore)]); +}; diff --git a/packages/ui/src/AssetSelector/shared/helpers.ts b/packages/ui/src/AssetSelector/shared/helpers.ts new file mode 100644 index 0000000000..e393e0434c --- /dev/null +++ b/packages/ui/src/AssetSelector/shared/helpers.ts @@ -0,0 +1,26 @@ +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; +import { AssetSelectorValue } from './types.ts'; + +/** Type predicate to check if a value is a `Metadata`. */ +export const isMetadata = (value?: AssetSelectorValue): value is Metadata => + value?.getType() === Metadata; + +/** Type predicate to check if a value is a `BalancesResponse`. */ +export const isBalancesResponse = (value?: AssetSelectorValue): value is BalancesResponse => + value?.getType() === BalancesResponse; + +/** returns a unique id of a specific Metadata or BalancesResponse */ +export const getHash = (value: AssetSelectorValue) => { + return uint8ArrayToHex(value.toBinary()); +}; + +/** compares Metadata or BalancesResponse with another option */ +export const isEqual = (value1: AssetSelectorValue, value2: AssetSelectorValue | undefined) => { + if (isMetadata(value1)) { + return isMetadata(value2) && value1.equals(value2); + } + + return isBalancesResponse(value2) && value1.equals(value2); +}; diff --git a/packages/ui/src/AssetSelector/shared/types.ts b/packages/ui/src/AssetSelector/shared/types.ts new file mode 100644 index 0000000000..b214ff7042 --- /dev/null +++ b/packages/ui/src/AssetSelector/shared/types.ts @@ -0,0 +1,29 @@ +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { ActionType } from '../../utils/ActionType.ts'; + +export type AssetSelectorValue = BalancesResponse | Metadata; + +export interface AssetSelectorBaseProps { + /** The value of the selected asset or balance */ + value?: AssetSelectorValue; + + /** Callback when the selected asset or balance changes */ + onChange?: (value: AssetSelectorValue) => void; + + /** The title of the dialog */ + dialogTitle?: string; + + /** + * What type of action is this component related to? Leave as `default` for most + * buttons, set to `accent` for the single most important action on a given + * page, set to `unshield` for actions that will unshield the user's funds, + * and set to `destructive` for destructive actions. + * + * Default: `default` + */ + actionType?: ActionType; + + /** Whether the asset selector is disabled */ + disabled?: boolean; +} diff --git a/packages/ui/src/Dialog/index.tsx b/packages/ui/src/Dialog/index.tsx index 917c57ffcd..4b9ba3c1d4 100644 --- a/packages/ui/src/Dialog/index.tsx +++ b/packages/ui/src/Dialog/index.tsx @@ -48,7 +48,9 @@ const DialogContent = styled.div` `; const DialogContentCard = styled(motion.div)` + position: relative; width: 100%; + max-height: 75%; box-sizing: border-box; background: ${props => props.theme.color.other.dialogBackground}; @@ -56,14 +58,8 @@ const DialogContentCard = styled(motion.div)` border-radius: ${props => props.theme.borderRadius.xl}; backdrop-filter: blur(${props => props.theme.blur.xl}); - padding-top: ${props => props.theme.spacing(8)}; - padding-bottom: ${props => props.theme.spacing(8)}; - padding-left: ${props => props.theme.spacing(6)}; - padding-right: ${props => props.theme.spacing(6)}; - display: flex; flex-direction: column; - gap: ${props => props.theme.spacing(6)}; /** * We add 'pointer-events: auto' here so that clicks _inside_ the content card @@ -73,10 +69,41 @@ const DialogContentCard = styled(motion.div)` pointer-events: auto; `; -const TitleAndCloseButton = styled.header` +const DialogChildrenWrap = styled.div` + overflow-y: auto; + display: flex; + flex-direction: column; + gap: ${props => props.theme.spacing(6)}; + + padding-bottom: ${props => props.theme.spacing(8)}; + padding-left: ${props => props.theme.spacing(6)}; + padding-right: ${props => props.theme.spacing(6)}; +`; + +const DialogHeader = styled.header` + position: sticky; + top: 0; + + display: flex; + flex-direction: column; + gap: ${props => props.theme.spacing(4)}; color: ${props => props.theme.color.text.primary}; - justify-content: space-between; + + padding-top: ${props => props.theme.spacing(8)}; + padding-bottom: ${props => props.theme.spacing(6)}; + padding-left: ${props => props.theme.spacing(6)}; + padding-right: ${props => props.theme.spacing(6)}; +`; + +/** + * Opening the dialog focuses the first focusable element in the dialog. That's why the Close button + * should be positioned absolutely and rendered as the last element in the dialog content. + */ +const DialogClose = styled.div` + position: absolute; + top: ${props => props.theme.spacing(8)}; + right: ${props => props.theme.spacing(6)}; `; interface ControlledDialogProps { @@ -233,6 +260,8 @@ const DialogContext = createContext<{ showCloseButton: boolean }>({ export interface DialogContentProps extends MotionProp { children?: ReactNode; + /** Renders the element after the dialog title. These elements will be sticky to the top of the dialog */ + headerChildren?: ReactNode; title: string; /** * If you want to render CTA buttons in the dialog footer, use @@ -246,6 +275,7 @@ export interface DialogContentProps({ children, + headerChildren, title, buttonGroupProps, motion, @@ -261,27 +291,32 @@ const Content = ({ - + {title} + {headerChildren} + + + + {children} - {showCloseButton && ( - - + {buttonGroupProps && } + + + {showCloseButton && ( + + + - - - )} - - - {children} - - {buttonGroupProps && } + + + + )} diff --git a/packages/ui/src/utils/bufs/metadata.ts b/packages/ui/src/utils/bufs/metadata.ts index 17637628b1..4a1d954bea 100644 --- a/packages/ui/src/utils/bufs/metadata.ts +++ b/packages/ui/src/utils/bufs/metadata.ts @@ -44,11 +44,10 @@ export const PENUMBRA_METADATA = new Metadata({ }, ], base: 'upenumbra', + name: 'Penumbra', display: 'penumbra', symbol: 'UM', - penumbraAssetId: { - altBaseDenom: 'KeqcLzNx9qSH5+lcJHBB9KNW+YPrBk5dKzvPMiypahA=', - }, + penumbraAssetId: new AssetId({ inner: u8(32) }), images: [ { svg: 'https://raw.githubusercontent.com/prax-wallet/registry/main/images/um.svg',