From 3f44e9bf09d9252860130675aed4e7860e944ec1 Mon Sep 17 00:00:00 2001 From: Atris Date: Thu, 29 Aug 2024 17:28:37 +0200 Subject: [PATCH 01/12] fix: storybook asset metadata --- packages/ui/src/utils/bufs/metadata.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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', From e1393b49ed29fec718834583142fdada470c0923 Mon Sep 17 00:00:00 2001 From: Atris Date: Thu, 29 Aug 2024 18:06:59 +0200 Subject: [PATCH 02/12] fix: make tailwind work in storybook, add icon to assetSelector --- packages/ui/.storybook/main.js | 10 +++++++++- packages/ui/.storybook/preview.jsx | 1 + packages/ui/src/AssetSelector/index.tsx | 9 +++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) 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/index.tsx b/packages/ui/src/AssetSelector/index.tsx index e783f76fe6..4b80a1a030 100644 --- a/packages/ui/src/AssetSelector/index.tsx +++ b/packages/ui/src/AssetSelector/index.tsx @@ -13,6 +13,8 @@ import { AssetSelectorDialogContent } from './AssetSelectorDialogContent'; import { motion } from 'framer-motion'; import { useId, useState } from 'react'; import { isMetadata } from './helpers'; +import { Icon } from '../Icon'; +import { ChevronsUpDownIcon } from 'lucide-react'; const Button = styled(motion.button)<{ $density: Density }>` ${buttonBase} @@ -82,6 +84,13 @@ export const AssetSelector = {metadata?.symbol} +
+ color.neutral.contrast} + /> +
)} From 63562dceeb7e10aa0fb74ae423d840d55f4aa045 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Thu, 5 Sep 2024 22:24:34 +0500 Subject: [PATCH 03/12] feat(ui): #1714: update AssetSelectorTrigger to the latest designs --- packages/ui/src/AssetSelector/Trigger.tsx | 139 ++++++++++++++++++++++ packages/ui/src/AssetSelector/index.tsx | 78 +++--------- 2 files changed, 156 insertions(+), 61 deletions(-) create mode 100644 packages/ui/src/AssetSelector/Trigger.tsx diff --git a/packages/ui/src/AssetSelector/Trigger.tsx b/packages/ui/src/AssetSelector/Trigger.tsx new file mode 100644 index 0000000000..887afe02dc --- /dev/null +++ b/packages/ui/src/AssetSelector/Trigger.tsx @@ -0,0 +1,139 @@ +import { ForwardedRef, forwardRef, MouseEventHandler } from 'react'; +import styled, { css } from 'styled-components'; +import { ChevronsUpDownIcon } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +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 './helpers.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< + ValueType extends (BalancesResponse | Metadata) | Metadata, +> { + value?: ValueType; + actionType?: ActionType; + disabled?: boolean; + onClick?: MouseEventHandler; + layoutId?: string; +} + +const AssetSelectorTriggerFunc = ( + { + value, + actionType = 'default', + disabled, + onClick, + layoutId, + }: AssetSelectorTriggerProps, + ref: ForwardedRef, +) => { + 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)} + /> + + + ); +}; + +export const AssetSelectorTrigger = forwardRef(AssetSelectorTriggerFunc) as < + ValueType extends (BalancesResponse | Metadata) | Metadata, +>( + props: AssetSelectorTriggerProps & { ref?: ForwardedRef }, +) => ReturnType; diff --git a/packages/ui/src/AssetSelector/index.tsx b/packages/ui/src/AssetSelector/index.tsx index a30dd23996..dc1017dcac 100644 --- a/packages/ui/src/AssetSelector/index.tsx +++ b/packages/ui/src/AssetSelector/index.tsx @@ -1,36 +1,10 @@ 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'; -import { Icon } from '../Icon'; -import { ChevronsUpDownIcon } from 'lucide-react'; - -const Button = styled(motion.button)<{ $density: Density }>` - ${buttonBase} - - 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 Row = styled.div<{ $density: Density }>` - display: flex; - gap: ${props => props.theme.spacing(props.$density === 'sparse' ? 2 : 1)}; - align-items: center; -`; +import { AssetSelectorTrigger } from './Trigger.tsx'; +import { ActionType } from '../utils/ActionType.ts'; export interface AssetSelectorProps { /** @@ -46,6 +20,9 @@ export interface AssetSelectorProps) => { const layoutId = useId(); - const density = useDensity(); - const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse.optional(value); const [isOpen, setIsOpen] = useState(false); @@ -73,37 +50,16 @@ export const AssetSelector = setIsOpen(false)}> - + + setIsOpen(true)} + /> + Date: Fri, 6 Sep 2024 13:07:12 +0500 Subject: [PATCH 04/12] feat(ui): #1714: implement `ListItem` nested component --- .../MetadataOrBalancesResponse/Balance.tsx | 31 ---- .../MetadataOrBalancesResponse/index.tsx | 90 ----------- .../index.tsx => Content.tsx} | 76 +++++---- packages/ui/src/AssetSelector/ListItem.tsx | 147 ++++++++++++++++++ packages/ui/src/AssetSelector/Trigger.tsx | 57 +++---- packages/ui/src/AssetSelector/index.tsx | 25 ++- ...erMetadataOrBalancesResponseByText.test.ts | 4 +- .../filterMetadataOrBalancesResponseByText.ts | 2 +- .../src/AssetSelector/{ => utils}/helpers.ts | 6 + 9 files changed, 235 insertions(+), 203 deletions(-) delete mode 100644 packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/Balance.tsx delete mode 100644 packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/index.tsx rename packages/ui/src/AssetSelector/{AssetSelectorDialogContent/index.tsx => Content.tsx} (53%) create mode 100644 packages/ui/src/AssetSelector/ListItem.tsx rename packages/ui/src/AssetSelector/{ => utils}/filterMetadataOrBalancesResponseByText.test.ts (95%) rename packages/ui/src/AssetSelector/{ => utils}/filterMetadataOrBalancesResponseByText.ts (95%) rename packages/ui/src/AssetSelector/{ => utils}/helpers.ts (69%) 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/Content.tsx similarity index 53% rename from packages/ui/src/AssetSelector/AssetSelectorDialogContent/index.tsx rename to packages/ui/src/AssetSelector/Content.tsx index 6acc3364e3..5a524bdc98 100644 --- a/packages/ui/src/AssetSelector/AssetSelectorDialogContent/index.tsx +++ b/packages/ui/src/AssetSelector/Content.tsx @@ -1,21 +1,18 @@ -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'; +import { RadioGroup } from '@radix-ui/react-radio-group'; +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { isBalancesResponse, isMetadata, getHash } from './utils/helpers.ts'; +import { Dialog } from '../Dialog'; +import { TextInput } from '../TextInput'; +import { Icon } from '../Icon'; +import { filterMetadataOrBalancesResponseByText } from './utils/filterMetadataOrBalancesResponseByText.ts'; +import { IsAnimatingProvider } from '../IsAnimatingProvider'; +import { ListItem } from './ListItem.tsx'; +import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; +import { ActionType } from '../utils/ActionType.ts'; const isEqual = ( value1: BalancesResponse | Metadata, @@ -28,17 +25,6 @@ const isEqual = ( 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; @@ -53,16 +39,18 @@ export interface AssetSelectorDialogContentProps< value?: ValueType; onChange: (value: ValueType) => void; options: ValueType[]; + actionType?: ActionType; + onClose?: VoidFunction; } -export const AssetSelectorDialogContent = < - ValueType extends (BalancesResponse | Metadata) | Metadata, ->({ +export const AssetSelectorContent = ({ title, layoutId, value, onChange, options, + actionType = 'default', + onClose, }: AssetSelectorDialogContentProps) => { const [search, setSearch] = useState(''); const filteredOptions = useMemo( @@ -70,6 +58,13 @@ export const AssetSelectorDialogContent = < [search, options], ); + const onValueChange = (hash: string) => { + const newValue = options.find(option => getHash(option) === hash); + if (newValue) { + onChange(newValue); + } + }; + return ( {props => ( @@ -83,16 +78,19 @@ export const AssetSelectorDialogContent = < placeholder='Search...' /> - - {filteredOptions.map(option => ( - onChange(option)} - /> - ))} - + + + {filteredOptions.map(option => ( + + ))} + + )} diff --git a/packages/ui/src/AssetSelector/ListItem.tsx b/packages/ui/src/AssetSelector/ListItem.tsx new file mode 100644 index 0000000000..17968f62fe --- /dev/null +++ b/packages/ui/src/AssetSelector/ListItem.tsx @@ -0,0 +1,147 @@ +import { RadioGroupItem } from '@radix-ui/react-radio-group'; +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 styled from 'styled-components'; +import { motion } from 'framer-motion'; +import { AssetIcon } from '../AssetIcon'; +import { Text } from '../Text'; +import { isBalancesResponse, isMetadata } from './utils/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 } from 'react'; + +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)}; + } + + &: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 Balance = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; +`; + +export interface ListItemProps { + value: Metadata | BalancesResponse; + isSelected: boolean; + actionType?: ActionType; + disabled?: boolean; + onClose?: VoidFunction; +} + +export const ListItem = ({ + value, + isSelected, + actionType = 'default', + disabled, + onClose, +}: ListItemProps) => { + const hash = uint8ArrayToHex(value.toBinary()); + + 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?.(); + } + }; + + 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/Trigger.tsx b/packages/ui/src/AssetSelector/Trigger.tsx index 887afe02dc..68f5fe1c20 100644 --- a/packages/ui/src/AssetSelector/Trigger.tsx +++ b/packages/ui/src/AssetSelector/Trigger.tsx @@ -12,7 +12,8 @@ import { asTransientProps } from '../utils/asTransientProps.ts'; import { Icon } from '../Icon'; import { Text } from '../Text'; import { AssetIcon } from '../AssetIcon'; -import { isMetadata } from './helpers.ts'; +import { isMetadata } from './utils/helpers.ts'; +import { Dialog } from '../Dialog/index.tsx'; const SparseButton = css` height: ${props => props.theme.spacing(12)}; @@ -101,34 +102,36 @@ const AssetSelectorTriggerFunc = - {!value ? ( - (disabled ? color.text.muted : color.text.primary)}> - Asset - - ) : ( - - - (disabled ? color.text.muted : color.text.primary)}> - {metadata?.symbol ?? 'Unknown'} + + + {!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)} - /> - - + + (disabled ? color.text.muted : color.text.primary)} + /> + + + ); }; diff --git a/packages/ui/src/AssetSelector/index.tsx b/packages/ui/src/AssetSelector/index.tsx index dc1017dcac..ce322ddf82 100644 --- a/packages/ui/src/AssetSelector/index.tsx +++ b/packages/ui/src/AssetSelector/index.tsx @@ -1,7 +1,7 @@ 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 { AssetSelectorDialogContent } from './AssetSelectorDialogContent'; +import { AssetSelectorContent } from './Content.tsx'; import { useId, useState } from 'react'; import { AssetSelectorTrigger } from './Trigger.tsx'; import { ActionType } from '../utils/ActionType.ts'; @@ -45,28 +45,27 @@ export const AssetSelector = { onChange(newValue); - setIsOpen(false); }; return ( setIsOpen(false)}> - - setIsOpen(true)} - /> - + setIsOpen(true)} + /> - setIsOpen(false)} /> ); diff --git a/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.test.ts b/packages/ui/src/AssetSelector/utils/filterMetadataOrBalancesResponseByText.test.ts similarity index 95% rename from packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.test.ts rename to packages/ui/src/AssetSelector/utils/filterMetadataOrBalancesResponseByText.test.ts index e3963ecf94..b6e2314b21 100644 --- a/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.test.ts +++ b/packages/ui/src/AssetSelector/utils/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/utils/filterMetadataOrBalancesResponseByText.ts similarity index 95% rename from packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.ts rename to packages/ui/src/AssetSelector/utils/filterMetadataOrBalancesResponseByText.ts index e92944cce9..7e5b9599dc 100644 --- a/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.ts +++ b/packages/ui/src/AssetSelector/utils/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/helpers.ts b/packages/ui/src/AssetSelector/utils/helpers.ts similarity index 69% rename from packages/ui/src/AssetSelector/helpers.ts rename to packages/ui/src/AssetSelector/utils/helpers.ts index f99f904b4e..237f0cf925 100644 --- a/packages/ui/src/AssetSelector/helpers.ts +++ b/packages/ui/src/AssetSelector/utils/helpers.ts @@ -1,5 +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 { uint8ArrayToHex } from '@penumbra-zone/types/hex'; /** Type predicate to check if a value is a `Metadata`. */ export const isMetadata = (value?: Metadata | BalancesResponse): value is Metadata => @@ -9,3 +10,8 @@ export const isMetadata = (value?: Metadata | BalancesResponse): value is Metada export const isBalancesResponse = ( value?: Metadata | BalancesResponse, ): value is BalancesResponse => value?.getType() === BalancesResponse; + +/** returns a unique id of a specific Metadata or BalancesResponse */ +export const getHash = (value: Metadata | BalancesResponse) => { + return uint8ArrayToHex(value.toBinary()); +}; From 88912d5d23e32890e3c0a24d29769b8dfa8eb314 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Sat, 7 Sep 2024 16:40:38 +0500 Subject: [PATCH 05/12] feat(ui): #1714: finish the implementation of the AssetSelector --- packages/ui/src/AssetSelector/Content.tsx | 98 ----------- packages/ui/src/AssetSelector/ListItem.tsx | 48 ++++-- .../ui/src/AssetSelector/SearchFilter.tsx | 21 +++ packages/ui/src/AssetSelector/Trigger.tsx | 98 +++++------ .../ui/src/AssetSelector/index.stories.tsx | 46 +++-- packages/ui/src/AssetSelector/index.tsx | 160 +++++++++++++----- .../ui/src/AssetSelector/utils/Context.tsx | 19 +++ .../filterMetadataOrBalancesResponseByText.ts | 1 + .../ui/src/AssetSelector/utils/helpers.ts | 20 ++- 9 files changed, 278 insertions(+), 233 deletions(-) delete mode 100644 packages/ui/src/AssetSelector/Content.tsx create mode 100644 packages/ui/src/AssetSelector/SearchFilter.tsx create mode 100644 packages/ui/src/AssetSelector/utils/Context.tsx diff --git a/packages/ui/src/AssetSelector/Content.tsx b/packages/ui/src/AssetSelector/Content.tsx deleted file mode 100644 index 5a524bdc98..0000000000 --- a/packages/ui/src/AssetSelector/Content.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import styled from 'styled-components'; -import { Search } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import { RadioGroup } from '@radix-ui/react-radio-group'; -import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; -import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; -import { isBalancesResponse, isMetadata, getHash } from './utils/helpers.ts'; -import { Dialog } from '../Dialog'; -import { TextInput } from '../TextInput'; -import { Icon } from '../Icon'; -import { filterMetadataOrBalancesResponseByText } from './utils/filterMetadataOrBalancesResponseByText.ts'; -import { IsAnimatingProvider } from '../IsAnimatingProvider'; -import { ListItem } from './ListItem.tsx'; -import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; -import { ActionType } from '../utils/ActionType.ts'; - -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 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[]; - actionType?: ActionType; - onClose?: VoidFunction; -} - -export const AssetSelectorContent = ({ - title, - layoutId, - value, - onChange, - options, - actionType = 'default', - onClose, -}: AssetSelectorDialogContentProps) => { - const [search, setSearch] = useState(''); - const filteredOptions = useMemo( - () => options.filter(filterMetadataOrBalancesResponseByText(search)), - [search, options], - ); - - const onValueChange = (hash: string) => { - const newValue = options.find(option => getHash(option) === hash); - if (newValue) { - onChange(newValue); - } - }; - - return ( - - {props => ( - - color.text.primary} /> - } - value={search} - onChange={setSearch} - placeholder='Search...' - /> - - - - {filteredOptions.map(option => ( - - ))} - - - - )} - - ); -}; diff --git a/packages/ui/src/AssetSelector/ListItem.tsx b/packages/ui/src/AssetSelector/ListItem.tsx index 17968f62fe..1518897e12 100644 --- a/packages/ui/src/AssetSelector/ListItem.tsx +++ b/packages/ui/src/AssetSelector/ListItem.tsx @@ -1,12 +1,10 @@ import { RadioGroupItem } from '@radix-ui/react-radio-group'; -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 styled from 'styled-components'; import { motion } from 'framer-motion'; import { AssetIcon } from '../AssetIcon'; import { Text } from '../Text'; -import { isBalancesResponse, isMetadata } from './utils/helpers.ts'; +import { isBalancesResponse, isEqual, isMetadata, SelectorValue } from './utils/helpers.ts'; import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; import { getAddressIndex, @@ -15,7 +13,8 @@ import { } from '@penumbra-zone/getters/balances-response'; import { ActionType, getOutlineColorByActionType } from '../utils/ActionType.ts'; import { asTransientProps } from '../utils/asTransientProps.ts'; -import { KeyboardEventHandler } from 'react'; +import { KeyboardEventHandler, MouseEventHandler } from 'react'; +import { useAssetsSelector } from './utils/Context.tsx'; const Root = styled(motion.button)<{ $isSelected: boolean; @@ -76,20 +75,20 @@ const Balance = styled.div` `; export interface ListItemProps { - value: Metadata | BalancesResponse; - isSelected: boolean; - actionType?: ActionType; + /** + * 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: SelectorValue; disabled?: boolean; - onClose?: VoidFunction; + actionType?: ActionType; } -export const ListItem = ({ - value, - isSelected, - actionType = 'default', - disabled, - onClose, -}: ListItemProps) => { +/** 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 isSelected = isEqual(value, selectedValue); const hash = uint8ArrayToHex(value.toBinary()); const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse.optional(value); @@ -103,16 +102,29 @@ export const ListItem = ({ const onEnter: KeyboardEventHandler = event => { if (event.key === 'Enter') { - onClose?.(); + 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 ( - + 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 index 68f5fe1c20..f04ecd0a34 100644 --- a/packages/ui/src/AssetSelector/Trigger.tsx +++ b/packages/ui/src/AssetSelector/Trigger.tsx @@ -1,9 +1,7 @@ -import { ForwardedRef, forwardRef, MouseEventHandler } from 'react'; +import { forwardRef, MouseEventHandler } from 'react'; import styled, { css } from 'styled-components'; import { ChevronsUpDownIcon } from 'lucide-react'; import { motion } from 'framer-motion'; -import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; -import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; import { ActionType, getOutlineColorByActionType } from '../utils/ActionType.ts'; import { Density } from '../types/Density.ts'; @@ -12,7 +10,7 @@ import { asTransientProps } from '../utils/asTransientProps.ts'; import { Icon } from '../Icon'; import { Text } from '../Text'; import { AssetIcon } from '../AssetIcon'; -import { isMetadata } from './utils/helpers.ts'; +import { isMetadata, SelectorValue } from './utils/helpers.ts'; import { Dialog } from '../Dialog/index.tsx'; const SparseButton = css` @@ -77,66 +75,52 @@ const IconAdornment = styled.i<{ $disabled?: boolean }>` props.$disabled ? props.theme.color.action.disabledOverlay : 'transparent'}; `; -export interface AssetSelectorTriggerProps< - ValueType extends (BalancesResponse | Metadata) | Metadata, -> { - value?: ValueType; +export interface AssetSelectorTriggerProps { + value?: SelectorValue; actionType?: ActionType; disabled?: boolean; onClick?: MouseEventHandler; layoutId?: string; } -const AssetSelectorTriggerFunc = ( - { - value, - actionType = 'default', - disabled, - onClick, - layoutId, - }: AssetSelectorTriggerProps, - ref: ForwardedRef, -) => { - const density = useDensity(); +export const AssetSelectorTrigger = forwardRef( + ({ value, actionType = 'default', disabled, onClick, layoutId }, ref) => { + const density = useDensity(); - const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse.optional(value); + 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'} + 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)} - /> - - - - ); -}; - -export const AssetSelectorTrigger = forwardRef(AssetSelectorTriggerFunc) as < - ValueType extends (BalancesResponse | Metadata) | Metadata, ->( - props: AssetSelectorTriggerProps & { ref?: ForwardedRef }, -) => ReturnType; + + (disabled ? color.text.muted : color.text.primary)} + /> + + + + ); + }, +); +AssetSelectorTrigger.displayName = 'AssetSelectorTrigger'; diff --git a/packages/ui/src/AssetSelector/index.stories.tsx b/packages/ui/src/AssetSelector/index.stories.tsx index 4210d81e54..2cf56eb0ad 100644 --- a/packages/ui/src/AssetSelector/index.stories.tsx +++ b/packages/ui/src/AssetSelector/index.stories.tsx @@ -1,10 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { useArgs } from '@storybook/preview-api'; import { AssetSelector } 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 { useMemo, useState } from 'react'; import { OSMO_BALANCE, OSMO_METADATA, @@ -13,6 +12,7 @@ import { PENUMBRA_METADATA, PIZZA_METADATA, } from '../utils/bufs'; +import { filterMetadataOrBalancesResponseByText } from './utils/filterMetadataOrBalancesResponseByText.ts'; const mixedOptions: (BalancesResponse | Metadata)[] = [ PIZZA_METADATA, @@ -27,7 +27,6 @@ const meta: Meta = { tags: ['autodocs', '!dev', 'density'], argTypes: { value: { control: false }, - options: { control: false }, }, }; export default meta; @@ -37,30 +36,47 @@ type Story = StoryObj; export const MixedBalancesResponsesAndMetadata: Story = { args: { dialogTitle: 'Transfer Assets', - value: PENUMBRA_BALANCE, - options: mixedOptions, }, render: function Render(props) { - const [, updateArgs] = useArgs(); + const [value, setValue] = useState(); + const [search, setSearch] = useState(''); - const onChange = (value: BalancesResponse | Metadata) => updateArgs({ value }); + const filteredOptions = useMemo( + () => mixedOptions.filter(filterMetadataOrBalancesResponseByText(search)), + [search], + ); - return ; + return ( + + {({ getKeyHash }) => + filteredOptions.map(option => ( + + )) + } + + ); }, }; export const MetadataOnly: Story = { render: function Render() { - const [value, setValue] = useState(PENUMBRA_METADATA); + const [value, setValue] = useState(PENUMBRA_METADATA); return ( - + + {({ getKeyHash }) => + metadataOnlyOptions.map(option => ( + + )) + } + ); }, }; diff --git a/packages/ui/src/AssetSelector/index.tsx b/packages/ui/src/AssetSelector/index.tsx index ce322ddf82..fad9db94af 100644 --- a/packages/ui/src/AssetSelector/index.tsx +++ b/packages/ui/src/AssetSelector/index.tsx @@ -1,72 +1,152 @@ -import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; -import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { ReactNode, useId, useState } from 'react'; +import styled from 'styled-components'; +import { RadioGroup } from '@radix-ui/react-radio-group'; import { Dialog } from '../Dialog'; -import { AssetSelectorContent } from './Content.tsx'; -import { useId, useState } from 'react'; -import { AssetSelectorTrigger } from './Trigger.tsx'; import { ActionType } from '../utils/ActionType.ts'; +import { IsAnimatingProvider } from '../IsAnimatingProvider'; +import { getHash, SelectorValue } from './utils/helpers.ts'; +import { AssetSelectorContext } from './utils/Context.tsx'; +import { AssetSelectorSearchFilter } from './SearchFilter.tsx'; +import { AssetSelectorTrigger } from './Trigger.tsx'; +import { ListItem } from './ListItem.tsx'; -export interface AssetSelectorProps { - /** - * The currently selected `Metadata` or `BalancesResponse`. - */ - value?: ValueType; - onChange: (value: ValueType) => void; +const OptionsWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${props => props.theme.spacing(1)}; +`; + +interface ChildrenArguments { + onClose: VoidFunction; /** - * 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. + * Takes the `Metadata` or `BalancesResponse` and returns + * a unique key string to be used within map in React */ - options: ValueType[]; - /** The title to show above the asset selector dialog when it opens. */ + getKeyHash: typeof getHash; +} + +export interface AssetSelectorProps { + /** The title to show above the asset selector dialog when it opens */ dialogTitle: string; + /** The currently selected `Metadata` or `BalancesResponse` */ + value?: SelectorValue; + /** Fires when the new `ListItem` gets selected */ + onChange?: (value: SelectorValue) => void; + actionType?: ActionType; disabled?: boolean; + + /** + * 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 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; } /** * 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. + * + * Use `AssetSelector.ListItem` inside the `AssetSelector` to render the options + * of the selector. It is up to the consumer to sort or group the options however they 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 AssetSelector = ({ +export const AssetSelector = ({ value, onChange, - options, dialogTitle, actionType, disabled, -}: AssetSelectorProps) => { + children, + search, + onSearchChange, +}: AssetSelectorProps) => { const layoutId = useId(); const [isOpen, setIsOpen] = useState(false); - const handleChange = (newValue: ValueType) => { - onChange(newValue); - }; + const onClose = () => setIsOpen(false); return ( setIsOpen(false)}> - setIsOpen(true)} - /> + + setIsOpen(true)} + /> + + + {props => ( + + {onSearchChange && ( + + )} - setIsOpen(false)} - /> + + + {typeof children === 'function' + ? children({ onClose, getKeyHash: getHash }) + : children} + + + + )} + + ); }; + +AssetSelector.ListItem = ListItem; diff --git a/packages/ui/src/AssetSelector/utils/Context.tsx b/packages/ui/src/AssetSelector/utils/Context.tsx new file mode 100644 index 0000000000..aba86879f8 --- /dev/null +++ b/packages/ui/src/AssetSelector/utils/Context.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; +import { SelectorValue } from './helpers.ts'; + +export interface AssetSelectorContextValue { + onClose: VoidFunction; + onChange?: (value: SelectorValue) => void; + value: SelectorValue | 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/utils/filterMetadataOrBalancesResponseByText.ts b/packages/ui/src/AssetSelector/utils/filterMetadataOrBalancesResponseByText.ts index 7e5b9599dc..80d02631b0 100644 --- a/packages/ui/src/AssetSelector/utils/filterMetadataOrBalancesResponseByText.ts +++ b/packages/ui/src/AssetSelector/utils/filterMetadataOrBalancesResponseByText.ts @@ -3,6 +3,7 @@ import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_ import { isMetadata } from './helpers.ts'; import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; +/** TODO: move to minifront */ export const filterMetadataOrBalancesResponseByText = (textSearch: string) => (value: Metadata | BalancesResponse): boolean => { diff --git a/packages/ui/src/AssetSelector/utils/helpers.ts b/packages/ui/src/AssetSelector/utils/helpers.ts index 237f0cf925..a9af495fe1 100644 --- a/packages/ui/src/AssetSelector/utils/helpers.ts +++ b/packages/ui/src/AssetSelector/utils/helpers.ts @@ -2,16 +2,26 @@ import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_p import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; +export type SelectorValue = BalancesResponse | Metadata; + /** Type predicate to check if a value is a `Metadata`. */ -export const isMetadata = (value?: Metadata | BalancesResponse): value is Metadata => +export const isMetadata = (value?: SelectorValue): 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; +export const isBalancesResponse = (value?: SelectorValue): value is BalancesResponse => + value?.getType() === BalancesResponse; /** returns a unique id of a specific Metadata or BalancesResponse */ -export const getHash = (value: Metadata | BalancesResponse) => { +export const getHash = (value: SelectorValue) => { return uint8ArrayToHex(value.toBinary()); }; + +/** compares Metadata or BalancesResponse with another option */ +export const isEqual = (value1: SelectorValue, value2: SelectorValue | undefined) => { + if (isMetadata(value1)) { + return isMetadata(value2) && value1.equals(value2); + } + + return isBalancesResponse(value2) && value1.equals(value2); +}; From de15c8dd5a60f3dbd9fb6573bac2cfcc9c7c16f8 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Mon, 9 Sep 2024 12:55:14 +0500 Subject: [PATCH 06/12] fix(ui): #1714: fix selected state of the AssetSelector --- packages/ui/src/AssetSelector/ListItem.tsx | 10 +++++----- packages/ui/src/AssetSelector/index.tsx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/AssetSelector/ListItem.tsx b/packages/ui/src/AssetSelector/ListItem.tsx index 1518897e12..5d21418b3e 100644 --- a/packages/ui/src/AssetSelector/ListItem.tsx +++ b/packages/ui/src/AssetSelector/ListItem.tsx @@ -1,10 +1,9 @@ import { RadioGroupItem } from '@radix-ui/react-radio-group'; -import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; import styled from 'styled-components'; import { motion } from 'framer-motion'; import { AssetIcon } from '../AssetIcon'; import { Text } from '../Text'; -import { isBalancesResponse, isEqual, isMetadata, SelectorValue } from './utils/helpers.ts'; +import { getHash, isBalancesResponse, isMetadata, SelectorValue } from './utils/helpers.ts'; import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; import { getAddressIndex, @@ -42,7 +41,8 @@ const Root = styled(motion.button)<{ ${props => props.theme.color.other.tonalFill5}; } - &:focus { + &:focus, + &[aria-checked='true'] { background: linear-gradient( 0deg, ${props => props.theme.color.action.hoverOverlay} 0%, @@ -88,8 +88,8 @@ export interface ListItemProps { export const ListItem = ({ value, disabled, actionType = 'default' }: ListItemProps) => { const { onClose, onChange, value: selectedValue } = useAssetsSelector(); - const isSelected = isEqual(value, selectedValue); - const hash = uint8ArrayToHex(value.toBinary()); + const hash = getHash(value); + const isSelected = !!selectedValue && getHash(value) === getHash(selectedValue); const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse.optional(value); diff --git a/packages/ui/src/AssetSelector/index.tsx b/packages/ui/src/AssetSelector/index.tsx index fad9db94af..a30538dd04 100644 --- a/packages/ui/src/AssetSelector/index.tsx +++ b/packages/ui/src/AssetSelector/index.tsx @@ -73,7 +73,7 @@ export interface AssetSelectorProps { * of the selector. It is up to the consumer to sort or group the options however they want. * * Example usage: - * + * * ```tsx * const [value, setValue] = useState(); * const [search, setSearch] = useState(''); @@ -134,7 +134,7 @@ export const AssetSelector = ({ )} - + {typeof children === 'function' ? children({ onClose, getKeyHash: getHash }) From 10da3f12f274fabb338692a1bdedc04378e92fcd Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Mon, 9 Sep 2024 14:52:56 +0500 Subject: [PATCH 07/12] feat(ui): #1714: separate `AssetSelector` from `AssetSelector.Custom` --- packages/ui/src/AssetSelector/Custom.tsx | 136 +++++++++++++ packages/ui/src/AssetSelector/ListItem.tsx | 12 +- packages/ui/src/AssetSelector/Trigger.tsx | 3 +- .../ui/src/AssetSelector/index.stories.tsx | 52 +---- packages/ui/src/AssetSelector/index.tsx | 189 +++++++++--------- .../{utils => shared}/Context.tsx | 2 +- ...erMetadataOrBalancesResponseByText.test.ts | 0 .../filterMetadataOrBalancesResponseByText.ts | 1 - .../src/AssetSelector/shared/groupAndSort.ts | 54 +++++ .../{utils => shared}/helpers.ts | 3 +- packages/ui/src/AssetSelector/shared/types.ts | 29 +++ 11 files changed, 334 insertions(+), 147 deletions(-) create mode 100644 packages/ui/src/AssetSelector/Custom.tsx rename packages/ui/src/AssetSelector/{utils => shared}/Context.tsx (93%) rename packages/ui/src/AssetSelector/{utils => shared}/filterMetadataOrBalancesResponseByText.test.ts (100%) rename packages/ui/src/AssetSelector/{utils => shared}/filterMetadataOrBalancesResponseByText.ts (96%) create mode 100644 packages/ui/src/AssetSelector/shared/groupAndSort.ts rename packages/ui/src/AssetSelector/{utils => shared}/helpers.ts (94%) create mode 100644 packages/ui/src/AssetSelector/shared/types.ts diff --git a/packages/ui/src/AssetSelector/Custom.tsx b/packages/ui/src/AssetSelector/Custom.tsx new file mode 100644 index 0000000000..7af4824b41 --- /dev/null +++ b/packages/ui/src/AssetSelector/Custom.tsx @@ -0,0 +1,136 @@ +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 => ( + + {onSearchChange && ( + + )} + + + + {typeof children === 'function' + ? children({ onClose, getKeyHash: getHash }) + : children} + + + + )} + + + + ); +}; diff --git a/packages/ui/src/AssetSelector/ListItem.tsx b/packages/ui/src/AssetSelector/ListItem.tsx index 5d21418b3e..f129658176 100644 --- a/packages/ui/src/AssetSelector/ListItem.tsx +++ b/packages/ui/src/AssetSelector/ListItem.tsx @@ -3,7 +3,7 @@ import styled from 'styled-components'; import { motion } from 'framer-motion'; import { AssetIcon } from '../AssetIcon'; import { Text } from '../Text'; -import { getHash, isBalancesResponse, isMetadata, SelectorValue } from './utils/helpers.ts'; +import { getHash, isBalancesResponse, isMetadata } from './shared/helpers.ts'; import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; import { getAddressIndex, @@ -13,7 +13,8 @@ import { import { ActionType, getOutlineColorByActionType } from '../utils/ActionType.ts'; import { asTransientProps } from '../utils/asTransientProps.ts'; import { KeyboardEventHandler, MouseEventHandler } from 'react'; -import { useAssetsSelector } from './utils/Context.tsx'; +import { useAssetsSelector } from './shared/Context.tsx'; +import { SelectorValue } from './shared/types.ts'; const Root = styled(motion.button)<{ $isSelected: boolean; @@ -41,8 +42,7 @@ const Root = styled(motion.button)<{ ${props => props.theme.color.other.tonalFill5}; } - &:focus, - &[aria-checked='true'] { + &:focus { background: linear-gradient( 0deg, ${props => props.theme.color.action.hoverOverlay} 0%, @@ -52,6 +52,10 @@ const Root = styled(motion.button)<{ 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, diff --git a/packages/ui/src/AssetSelector/Trigger.tsx b/packages/ui/src/AssetSelector/Trigger.tsx index f04ecd0a34..4267b33149 100644 --- a/packages/ui/src/AssetSelector/Trigger.tsx +++ b/packages/ui/src/AssetSelector/Trigger.tsx @@ -10,8 +10,9 @@ import { asTransientProps } from '../utils/asTransientProps.ts'; import { Icon } from '../Icon'; import { Text } from '../Text'; import { AssetIcon } from '../AssetIcon'; -import { isMetadata, SelectorValue } from './utils/helpers.ts'; +import { isMetadata } from './shared/helpers.ts'; import { Dialog } from '../Dialog/index.tsx'; +import { SelectorValue } from './shared/types.ts'; const SparseButton = css` height: ${props => props.theme.spacing(12)}; diff --git a/packages/ui/src/AssetSelector/index.stories.tsx b/packages/ui/src/AssetSelector/index.stories.tsx index 2cf56eb0ad..3839c1a5ae 100644 --- a/packages/ui/src/AssetSelector/index.stories.tsx +++ b/packages/ui/src/AssetSelector/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { AssetSelector } 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 { useMemo, useState } from 'react'; +import { useState } from 'react'; import { OSMO_BALANCE, OSMO_METADATA, @@ -12,15 +12,9 @@ import { PENUMBRA_METADATA, PIZZA_METADATA, } from '../utils/bufs'; -import { filterMetadataOrBalancesResponseByText } from './utils/filterMetadataOrBalancesResponseByText.ts'; -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, @@ -36,47 +30,13 @@ type Story = StoryObj; export const MixedBalancesResponsesAndMetadata: Story = { args: { dialogTitle: 'Transfer Assets', + assets: assetOptions, + balances: balanceOptions, }, render: function Render(props) { const [value, setValue] = useState(); - const [search, setSearch] = useState(''); - - const filteredOptions = useMemo( - () => mixedOptions.filter(filterMetadataOrBalancesResponseByText(search)), - [search], - ); - - return ( - - {({ getKeyHash }) => - filteredOptions.map(option => ( - - )) - } - - ); - }, -}; - -export const MetadataOnly: Story = { - render: function Render() { - const [value, setValue] = useState(PENUMBRA_METADATA); - return ( - - {({ getKeyHash }) => - metadataOnlyOptions.map(option => ( - - )) - } - - ); + return ; }, }; diff --git a/packages/ui/src/AssetSelector/index.tsx b/packages/ui/src/AssetSelector/index.tsx index a30538dd04..d6884f9983 100644 --- a/packages/ui/src/AssetSelector/index.tsx +++ b/packages/ui/src/AssetSelector/index.tsx @@ -1,66 +1,38 @@ -import { ReactNode, useId, useState } from 'react'; -import styled from 'styled-components'; -import { RadioGroup } from '@radix-ui/react-radio-group'; -import { Dialog } from '../Dialog'; -import { ActionType } from '../utils/ActionType.ts'; -import { IsAnimatingProvider } from '../IsAnimatingProvider'; -import { getHash, SelectorValue } from './utils/helpers.ts'; -import { AssetSelectorContext } from './utils/Context.tsx'; -import { AssetSelectorSearchFilter } from './SearchFilter.tsx'; -import { AssetSelectorTrigger } from './Trigger.tsx'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; + +import { filterMetadataOrBalancesResponseByText } from './shared/filterMetadataOrBalancesResponseByText.ts'; +import { AssetSelectorCustom } from './Custom.tsx'; import { ListItem } from './ListItem.tsx'; +import { Text } from '../Text'; +import { useMemo, useState } from 'react'; +import { AssetSelectorBaseProps } from './shared/types.ts'; +import styled from 'styled-components'; +import { groupAndSort } from './shared/groupAndSort.ts'; -const OptionsWrapper = styled.div` +const ListItemGroup = styled.div` display: flex; flex-direction: column; gap: ${props => props.theme.spacing(1)}; `; -interface ChildrenArguments { - onClose: VoidFunction; +const SelectorList = styled.div` + display: flex; + flex-direction: column; + gap: ${props => props.theme.spacing(4)}; +`; + +export interface AssetSelectorProps extends AssetSelectorBaseProps { /** - * Takes the `Metadata` or `BalancesResponse` and returns - * a unique key string to be used within map in React + * An array of `Metadata` – protobuf message types describing the asset: + * its name, symbol, id, icons, and more */ - getKeyHash: typeof getHash; -} - -export interface AssetSelectorProps { - /** The title to show above the asset selector dialog when it opens */ - dialogTitle: string; - - /** The currently selected `Metadata` or `BalancesResponse` */ - value?: SelectorValue; - /** Fires when the new `ListItem` gets selected */ - onChange?: (value: SelectorValue) => void; - - actionType?: ActionType; - disabled?: boolean; - + assets?: Metadata[]; /** - * 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 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; + * 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[]; } /** @@ -69,10 +41,30 @@ export interface AssetSelectorProps { * 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. * - * Use `AssetSelector.ListItem` inside the `AssetSelector` to render the options - * of the selector. It is up to the consumer to sort or group the options however they want. + * The component has two ways of using it: * - * Example usage: + * ### 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(); @@ -84,7 +76,7 @@ export interface AssetSelectorProps { * ); * * return ( - * { - const layoutId = useId(); - - const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); - const onClose = () => setIsOpen(false); + const { filteredAssets, filteredBalances } = useMemo( + () => ({ + filteredAssets: assets.filter(filterMetadataOrBalancesResponseByText(search)), + filteredBalances: groupAndSort( + balances.filter(filterMetadataOrBalancesResponseByText(search)), + ), + }), + [assets, balances, search], + ); return ( - setIsOpen(false)}> - - setIsOpen(true)} - /> + + {({ getKeyHash }) => ( + + {!!filteredBalances.length && ( + color.text.secondary}> + Your Tokens + + )} - - {props => ( - - {onSearchChange && ( - - )} + {filteredBalances.map(([account, balances]) => ( + + {balances.map(balance => ( + + ))} + + ))} - - - {typeof children === 'function' - ? children({ onClose, getKeyHash: getHash }) - : children} - - - + {!!filteredAssets.length && ( + color.text.secondary}> + All Tokens + )} - - - + + {filteredAssets.map(asset => ( + + ))} + + )} + ); }; +AssetSelector.Custom = AssetSelectorCustom; AssetSelector.ListItem = ListItem; diff --git a/packages/ui/src/AssetSelector/utils/Context.tsx b/packages/ui/src/AssetSelector/shared/Context.tsx similarity index 93% rename from packages/ui/src/AssetSelector/utils/Context.tsx rename to packages/ui/src/AssetSelector/shared/Context.tsx index aba86879f8..c51a409516 100644 --- a/packages/ui/src/AssetSelector/utils/Context.tsx +++ b/packages/ui/src/AssetSelector/shared/Context.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from 'react'; -import { SelectorValue } from './helpers.ts'; +import { SelectorValue } from './types.ts'; export interface AssetSelectorContextValue { onClose: VoidFunction; diff --git a/packages/ui/src/AssetSelector/utils/filterMetadataOrBalancesResponseByText.test.ts b/packages/ui/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.test.ts similarity index 100% rename from packages/ui/src/AssetSelector/utils/filterMetadataOrBalancesResponseByText.test.ts rename to packages/ui/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.test.ts diff --git a/packages/ui/src/AssetSelector/utils/filterMetadataOrBalancesResponseByText.ts b/packages/ui/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.ts similarity index 96% rename from packages/ui/src/AssetSelector/utils/filterMetadataOrBalancesResponseByText.ts rename to packages/ui/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.ts index 80d02631b0..7e5b9599dc 100644 --- a/packages/ui/src/AssetSelector/utils/filterMetadataOrBalancesResponseByText.ts +++ b/packages/ui/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.ts @@ -3,7 +3,6 @@ import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_ import { isMetadata } from './helpers.ts'; import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; -/** TODO: move to minifront */ export const filterMetadataOrBalancesResponseByText = (textSearch: string) => (value: Metadata | BalancesResponse): boolean => { diff --git a/packages/ui/src/AssetSelector/shared/groupAndSort.ts b/packages/ui/src/AssetSelector/shared/groupAndSort.ts new file mode 100644 index 0000000000..c96aeb0d66 --- /dev/null +++ b/packages/ui/src/AssetSelector/shared/groupAndSort.ts @@ -0,0 +1,54 @@ +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { + getAmount, + getMetadataFromBalancesResponse, + getAddressIndex, +} from '@penumbra-zone/getters/balances-response'; +import { joinLoHiAmount, multiplyAmountByNumber } from '@penumbra-zone/types/amount'; + +const groupByAccount = ( + acc: Record, + curr: BalancesResponse, +): Record => { + const index = getAddressIndex.optional(curr)?.account; + + if (index === undefined) { + 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/utils/helpers.ts b/packages/ui/src/AssetSelector/shared/helpers.ts similarity index 94% rename from packages/ui/src/AssetSelector/utils/helpers.ts rename to packages/ui/src/AssetSelector/shared/helpers.ts index a9af495fe1..fa6b4d7dc0 100644 --- a/packages/ui/src/AssetSelector/utils/helpers.ts +++ b/packages/ui/src/AssetSelector/shared/helpers.ts @@ -1,8 +1,7 @@ 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'; - -export type SelectorValue = BalancesResponse | Metadata; +import { SelectorValue } from './types.ts'; /** Type predicate to check if a value is a `Metadata`. */ export const isMetadata = (value?: SelectorValue): value is Metadata => diff --git a/packages/ui/src/AssetSelector/shared/types.ts b/packages/ui/src/AssetSelector/shared/types.ts new file mode 100644 index 0000000000..a5b33888e5 --- /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 SelectorValue = BalancesResponse | Metadata; + +export interface AssetSelectorBaseProps { + /** The value of the selected asset or balance */ + value?: SelectorValue; + + /** Callback when the selected asset or balance changes */ + onChange?: (value: SelectorValue) => 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; +} From 5a76fdd87991a5e104f8af6480561db78ed96363 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Mon, 9 Sep 2024 14:54:08 +0500 Subject: [PATCH 08/12] chore: changeset --- .changeset/happy-mayflies-destroy.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/happy-mayflies-destroy.md 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 From b8db9a8827bb3c3b06a435fb11024a263ca31219 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Mon, 9 Sep 2024 17:00:55 +0500 Subject: [PATCH 09/12] refactor(ui): #1714: rename `AssetSelectorValue`, export types --- packages/ui/src/AssetSelector/ListItem.tsx | 4 ++-- packages/ui/src/AssetSelector/Trigger.tsx | 4 ++-- packages/ui/src/AssetSelector/index.stories.tsx | 4 ++-- packages/ui/src/AssetSelector/index.tsx | 12 +++++++----- packages/ui/src/AssetSelector/shared/Context.tsx | 6 +++--- packages/ui/src/AssetSelector/shared/helpers.ts | 10 +++++----- packages/ui/src/AssetSelector/shared/types.ts | 6 +++--- 7 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/AssetSelector/ListItem.tsx b/packages/ui/src/AssetSelector/ListItem.tsx index f129658176..b7bd8b0585 100644 --- a/packages/ui/src/AssetSelector/ListItem.tsx +++ b/packages/ui/src/AssetSelector/ListItem.tsx @@ -14,7 +14,7 @@ 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 { SelectorValue } from './shared/types.ts'; +import { AssetSelectorValue } from './shared/types.ts'; const Root = styled(motion.button)<{ $isSelected: boolean; @@ -83,7 +83,7 @@ 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: SelectorValue; + value: AssetSelectorValue; disabled?: boolean; actionType?: ActionType; } diff --git a/packages/ui/src/AssetSelector/Trigger.tsx b/packages/ui/src/AssetSelector/Trigger.tsx index 4267b33149..033d1dbecf 100644 --- a/packages/ui/src/AssetSelector/Trigger.tsx +++ b/packages/ui/src/AssetSelector/Trigger.tsx @@ -12,7 +12,7 @@ import { Text } from '../Text'; import { AssetIcon } from '../AssetIcon'; import { isMetadata } from './shared/helpers.ts'; import { Dialog } from '../Dialog/index.tsx'; -import { SelectorValue } from './shared/types.ts'; +import { AssetSelectorValue } from './shared/types.ts'; const SparseButton = css` height: ${props => props.theme.spacing(12)}; @@ -77,7 +77,7 @@ const IconAdornment = styled.i<{ $disabled?: boolean }>` `; export interface AssetSelectorTriggerProps { - value?: SelectorValue; + value?: AssetSelectorValue; actionType?: ActionType; disabled?: boolean; onClick?: MouseEventHandler; diff --git a/packages/ui/src/AssetSelector/index.stories.tsx b/packages/ui/src/AssetSelector/index.stories.tsx index 3839c1a5ae..f11608b0e2 100644 --- a/packages/ui/src/AssetSelector/index.stories.tsx +++ b/packages/ui/src/AssetSelector/index.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; -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'; @@ -35,7 +35,7 @@ export const MixedBalancesResponsesAndMetadata: Story = { }, render: function Render(props) { - const [value, setValue] = useState(); + const [value, setValue] = useState(); return ; }, diff --git a/packages/ui/src/AssetSelector/index.tsx b/packages/ui/src/AssetSelector/index.tsx index d6884f9983..cfd44cc0f5 100644 --- a/packages/ui/src/AssetSelector/index.tsx +++ b/packages/ui/src/AssetSelector/index.tsx @@ -1,13 +1,13 @@ +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 { filterMetadataOrBalancesResponseByText } from './shared/filterMetadataOrBalancesResponseByText.ts'; -import { AssetSelectorCustom } from './Custom.tsx'; -import { ListItem } from './ListItem.tsx'; +import { AssetSelectorBaseProps, AssetSelectorValue } from './shared/types.ts'; +import { AssetSelectorCustom, AssetSelectorCustomProps } from './Custom.tsx'; +import { ListItem, ListItemProps } from './ListItem.tsx'; import { Text } from '../Text'; -import { useMemo, useState } from 'react'; -import { AssetSelectorBaseProps } from './shared/types.ts'; -import styled from 'styled-components'; import { groupAndSort } from './shared/groupAndSort.ts'; const ListItemGroup = styled.div` @@ -155,3 +155,5 @@ export const AssetSelector = ({ AssetSelector.Custom = AssetSelectorCustom; AssetSelector.ListItem = ListItem; + +export type { AssetSelectorValue, AssetSelectorCustomProps, ListItemProps }; diff --git a/packages/ui/src/AssetSelector/shared/Context.tsx b/packages/ui/src/AssetSelector/shared/Context.tsx index c51a409516..43255a1d22 100644 --- a/packages/ui/src/AssetSelector/shared/Context.tsx +++ b/packages/ui/src/AssetSelector/shared/Context.tsx @@ -1,10 +1,10 @@ import { createContext, useContext } from 'react'; -import { SelectorValue } from './types.ts'; +import { AssetSelectorValue } from './types.ts'; export interface AssetSelectorContextValue { onClose: VoidFunction; - onChange?: (value: SelectorValue) => void; - value: SelectorValue | undefined; + onChange?: (value: AssetSelectorValue) => void; + value: AssetSelectorValue | undefined; } /** diff --git a/packages/ui/src/AssetSelector/shared/helpers.ts b/packages/ui/src/AssetSelector/shared/helpers.ts index fa6b4d7dc0..e393e0434c 100644 --- a/packages/ui/src/AssetSelector/shared/helpers.ts +++ b/packages/ui/src/AssetSelector/shared/helpers.ts @@ -1,23 +1,23 @@ 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 { SelectorValue } from './types.ts'; +import { AssetSelectorValue } from './types.ts'; /** Type predicate to check if a value is a `Metadata`. */ -export const isMetadata = (value?: SelectorValue): value is 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?: SelectorValue): value is 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: SelectorValue) => { +export const getHash = (value: AssetSelectorValue) => { return uint8ArrayToHex(value.toBinary()); }; /** compares Metadata or BalancesResponse with another option */ -export const isEqual = (value1: SelectorValue, value2: SelectorValue | undefined) => { +export const isEqual = (value1: AssetSelectorValue, value2: AssetSelectorValue | undefined) => { if (isMetadata(value1)) { return isMetadata(value2) && value1.equals(value2); } diff --git a/packages/ui/src/AssetSelector/shared/types.ts b/packages/ui/src/AssetSelector/shared/types.ts index a5b33888e5..b214ff7042 100644 --- a/packages/ui/src/AssetSelector/shared/types.ts +++ b/packages/ui/src/AssetSelector/shared/types.ts @@ -2,14 +2,14 @@ import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_ import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; import { ActionType } from '../../utils/ActionType.ts'; -export type SelectorValue = BalancesResponse | Metadata; +export type AssetSelectorValue = BalancesResponse | Metadata; export interface AssetSelectorBaseProps { /** The value of the selected asset or balance */ - value?: SelectorValue; + value?: AssetSelectorValue; /** Callback when the selected asset or balance changes */ - onChange?: (value: SelectorValue) => void; + onChange?: (value: AssetSelectorValue) => void; /** The title of the dialog */ dialogTitle?: string; From 4d41815a221cbdd836f421a1cbb504c926392353 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Wed, 11 Sep 2024 13:00:47 +0500 Subject: [PATCH 10/12] feat(ui): #1714: implement sticky header and the correct height in the dialog --- packages/ui/src/AssetSelector/Custom.tsx | 15 +++-- packages/ui/src/Dialog/index.tsx | 75 +++++++++++++++++------- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/packages/ui/src/AssetSelector/Custom.tsx b/packages/ui/src/AssetSelector/Custom.tsx index 7af4824b41..0735f42689 100644 --- a/packages/ui/src/AssetSelector/Custom.tsx +++ b/packages/ui/src/AssetSelector/Custom.tsx @@ -115,11 +115,16 @@ export const AssetSelectorCustom = ({ {props => ( - - {onSearchChange && ( - - )} - + + ) + } + > {typeof children === 'function' 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 && } + + + + )} From 122abf24923e3049d6ee50bc206ada4a68118324 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Thu, 12 Sep 2024 14:02:01 +0500 Subject: [PATCH 11/12] fix(ui): #1714: update default filtering of the balances and fix ListItem styles --- packages/ui/src/AssetSelector/ListItem.tsx | 33 ++++++++++++++++--- .../src/AssetSelector/shared/groupAndSort.ts | 29 +++++++++++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/AssetSelector/ListItem.tsx b/packages/ui/src/AssetSelector/ListItem.tsx index b7bd8b0585..9d94ba325d 100644 --- a/packages/ui/src/AssetSelector/ListItem.tsx +++ b/packages/ui/src/AssetSelector/ListItem.tsx @@ -15,6 +15,7 @@ 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; @@ -72,6 +73,26 @@ const AssetInfo = styled.div` 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; @@ -133,12 +154,16 @@ export const ListItem = ({ value, disabled, actionType = 'default' }: ListItemPr
-
+ {balance?.valueView && ( - {getFormattedAmtFromValueView(balance.valueView, true)} + + {getFormattedAmtFromValueView(balance.valueView, true)}{' '} + )} - {metadata?.symbol ?? 'Unknown'} -
+ + {metadata?.symbol ?? 'Unknown'} + + {metadata?.name && ( color.text.secondary} as='div'> {metadata.name} diff --git a/packages/ui/src/AssetSelector/shared/groupAndSort.ts b/packages/ui/src/AssetSelector/shared/groupAndSort.ts index c96aeb0d66..07483e0367 100644 --- a/packages/ui/src/AssetSelector/shared/groupAndSort.ts +++ b/packages/ui/src/AssetSelector/shared/groupAndSort.ts @@ -3,8 +3,35 @@ 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, @@ -12,7 +39,7 @@ const groupByAccount = ( ): Record => { const index = getAddressIndex.optional(curr)?.account; - if (index === undefined) { + if (index === undefined || isUnknownBalance(curr) || isUnswappable(curr)) { return acc; } From 15b9b544b7667776d992ebf5e3c2c0f9b4636197 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Thu, 12 Sep 2024 15:06:20 +0500 Subject: [PATCH 12/12] fix(ui): #1714: export useful helpers from the AssetSelector --- packages/ui/src/AssetSelector/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/src/AssetSelector/index.tsx b/packages/ui/src/AssetSelector/index.tsx index cfd44cc0f5..23c0e2e8d2 100644 --- a/packages/ui/src/AssetSelector/index.tsx +++ b/packages/ui/src/AssetSelector/index.tsx @@ -3,6 +3,7 @@ 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 { 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'; @@ -156,4 +157,6 @@ export const AssetSelector = ({ AssetSelector.Custom = AssetSelectorCustom; AssetSelector.ListItem = ListItem; +export { isBalancesResponse, isMetadata }; + export type { AssetSelectorValue, AssetSelectorCustomProps, ListItemProps };