diff --git a/.changeset/bright-vans-hear.md b/.changeset/bright-vans-hear.md new file mode 100644 index 000000000..ea216aa00 --- /dev/null +++ b/.changeset/bright-vans-hear.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/getters': major +--- + +Update asset ID getter; it no longer has a hardcoded Optional version diff --git a/apps/minifront/src/components/send/send-form/index.tsx b/apps/minifront/src/components/send/send-form/index.tsx index c1a6570f1..8b715ccba 100644 --- a/apps/minifront/src/components/send/send-form/index.tsx +++ b/apps/minifront/src/components/send/send-form/index.tsx @@ -6,11 +6,11 @@ import { InputBlock } from '../../shared/input-block'; import { useMemo } from 'react'; import { penumbraAddrValidation } from '../helpers'; import InputToken from '../../shared/input-token'; -import { useRefreshFee } from './use-refresh-fee'; import { GasFee } from '../../shared/gas-fee'; import { useBalancesResponses, useStakingTokenMetadata } from '../../../state/shared'; import { NonNativeFeeWarning } from '../../shared/non-native-fee-warning'; import { transferableBalancesResponsesSelector } from '../../../state/send/helpers'; +import { useRefreshFee } from '../../v2/transfer-layout/send-page/use-refresh-fee'; export const SendForm = () => { const stakingTokenMetadata = useStakingTokenMetadata(); diff --git a/apps/minifront/src/components/shared/non-native-fee-warning.tsx b/apps/minifront/src/components/shared/non-native-fee-warning.tsx index 367d80ac7..4c150c749 100644 --- a/apps/minifront/src/components/shared/non-native-fee-warning.tsx +++ b/apps/minifront/src/components/shared/non-native-fee-warning.tsx @@ -6,7 +6,7 @@ import { ReactNode, useCallback, useEffect, useState } from 'react'; import { getAddressIndex, getAmount, - getAssetIdFromBalancesResponseOptional, + getAssetIdFromBalancesResponse, } from '@penumbra-zone/getters/balances-response'; import { ViewService } from '@penumbra-zone/protobuf'; import { GasPrices } from '@penumbra-zone/protobuf/penumbra/core/component/fee/v1/fee_pb'; @@ -54,7 +54,7 @@ const hasTokenBalance = ({ } return gasPrices.some(price => - price.assetId?.equals(getAssetIdFromBalancesResponseOptional(balance)), + price.assetId?.equals(getAssetIdFromBalancesResponse.optional()(balance)), ); }); diff --git a/apps/minifront/src/components/v2/dashboard-layout/assets-card-title.tsx b/apps/minifront/src/components/v2/dashboard-layout/assets-card-title.tsx index 73026aaf3..a3ea04b31 100644 --- a/apps/minifront/src/components/v2/dashboard-layout/assets-card-title.tsx +++ b/apps/minifront/src/components/v2/dashboard-layout/assets-card-title.tsx @@ -3,23 +3,27 @@ import { CharacterTransition } from '@repo/ui/CharacterTransition'; import { Dialog } from '@repo/ui/Dialog'; import { Text } from '@repo/ui/Text'; import { Info } from 'lucide-react'; +import { useId } from 'react'; -export const AssetsCardTitle = () => ( -
- Asset Balances - - - - - - - Your balances are shielded, and are known only to you. They are not visible on chain. Each - Penumbra wallet controls many numbered accounts, each with its own balance. Account - information is never revealed on-chain. - - - -
-); +export const AssetsCardTitle = () => { + const layoutId = useId(); + return ( +
+ Asset Balances + + + + + + + Your balances are shielded, and are known only to you. They are not visible on chain. + Each Penumbra wallet controls many numbered accounts, each with its own balance. Account + information is never revealed on-chain. + + + +
+ ); +}; diff --git a/apps/minifront/src/components/v2/dashboard-layout/assets-page/index.tsx b/apps/minifront/src/components/v2/dashboard-layout/assets-page/index.tsx index 81fca93d0..a78b0e067 100644 --- a/apps/minifront/src/components/v2/dashboard-layout/assets-page/index.tsx +++ b/apps/minifront/src/components/v2/dashboard-layout/assets-page/index.tsx @@ -14,6 +14,9 @@ import { TableTitle } from './table-title'; import { Link } from 'react-router-dom'; import { Button } from '@repo/ui/Button'; import { ArrowRightLeft } from 'lucide-react'; +import { useAnimationDeferredValue } from '@repo/ui/hooks/useAnimationDeferredValue'; +import { ConditionalWrap } from '@repo/ui/ConditionalWrap'; +import { LayoutGroup } from 'framer-motion'; const getTradeLink = (balance: BalancesResponse): string => { const metadata = getMetadataFromBalancesResponseOptional(balance); @@ -36,46 +39,69 @@ export const AssetsPage = () => { shouldReselect: (before, after) => before?.data !== after.data, }); - return ( - <> - {balancesByAccount?.map(account => ( - }> - - - Asset - Estimate - - - - - {account.balances.map((balance, index) => ( - - - - - - - + const deferredBalancesByAccount = useAnimationDeferredValue(balancesByAccount); + + return deferredBalancesByAccount?.map((account, index) => ( + `, since that's the + // only one that will be animating when transitioning to the assets table. + if={index === 0} + then={children => {children}} + > +
} + motion={{ layoutId: index === 0 ? 'table' : undefined }} + > + + + + Asset + + + Estimate + + + + + + {account.balances.map((balance, index) => ( + + + + + + + - -
- - - - - -
-
-
- ))} -
-
- ))} - - ); + +
+ + + + + +
+
+ + ))} + + + + )); }; diff --git a/apps/minifront/src/components/v2/dashboard-layout/index.tsx b/apps/minifront/src/components/v2/dashboard-layout/index.tsx index 01956bbe9..6297e8df8 100644 --- a/apps/minifront/src/components/v2/dashboard-layout/index.tsx +++ b/apps/minifront/src/components/v2/dashboard-layout/index.tsx @@ -6,7 +6,7 @@ import { usePagePath } from '../../../fetchers/page-path'; import { PagePath } from '../../metadata/paths'; import { AssetsCardTitle } from './assets-card-title'; import { TransactionsCardTitle } from './transactions-card-title'; -import { LayoutGroup, motion } from 'framer-motion'; +import { motion } from 'framer-motion'; /** @todo: Remove this function and its uses after we switch to v2 layout */ const v2PathPrefix = (path: string) => `/v2${path}`; @@ -30,22 +30,21 @@ export const DashboardLayout = () => { - - - - navigate(value)} - options={TABS_OPTIONS} - actionType='accent' - /> - - - - - - - + + + navigate(value)} + options={TABS_OPTIONS} + actionType='accent' + /> + + + + diff --git a/apps/minifront/src/components/v2/dashboard-layout/transactions-card-title.tsx b/apps/minifront/src/components/v2/dashboard-layout/transactions-card-title.tsx index 5b01a213d..f02a127b1 100644 --- a/apps/minifront/src/components/v2/dashboard-layout/transactions-card-title.tsx +++ b/apps/minifront/src/components/v2/dashboard-layout/transactions-card-title.tsx @@ -3,22 +3,26 @@ import { CharacterTransition } from '@repo/ui/CharacterTransition'; import { Dialog } from '@repo/ui/Dialog'; import { Text } from '@repo/ui/Text'; import { Info } from 'lucide-react'; +import { useId } from 'react'; -export const TransactionsCardTitle = () => ( -
- Transactions List - - - - - - - Your wallet scans shielded chain data locally and indexes all relevant transactions it - detects, both incoming and outgoing. - - - -
-); +export const TransactionsCardTitle = () => { + const layoutId = useId(); + return ( +
+ Transactions List + + + + + + + Your wallet scans shielded chain data locally and indexes all relevant transactions it + detects, both incoming and outgoing. + + + +
+ ); +}; diff --git a/apps/minifront/src/components/v2/dashboard-layout/transactions-page/index.tsx b/apps/minifront/src/components/v2/dashboard-layout/transactions-page/index.tsx index 8515c65c1..f0d249d85 100644 --- a/apps/minifront/src/components/v2/dashboard-layout/transactions-page/index.tsx +++ b/apps/minifront/src/components/v2/dashboard-layout/transactions-page/index.tsx @@ -4,49 +4,58 @@ import { Text } from '@repo/ui/Text'; import { Link } from 'react-router-dom'; import { SquareArrowOutUpRight } from 'lucide-react'; import { Button } from '@repo/ui/Button'; +import { useAnimationDeferredValue } from '@repo/ui/hooks/useAnimationDeferredValue'; +import { LayoutGroup } from 'framer-motion'; export const TransactionsPage = () => { const summaries = useSummaries(); + const deferredSummariesData = useAnimationDeferredValue(summaries.data); return ( - - - - Block Height - Description - Hash - - - - {summaries.data?.map(summary => ( - - - {summary.height} - - - {summary.description} - - -
- - - {summary.hash} - - - - - -
-
+ +
+ + + + Block Height + + Description + + Hash + - ))} - -
+ + + {deferredSummariesData?.map(summary => ( + + + {summary.height} + + + {summary.description} + + +
+ + + {summary.hash} + + + + + +
+
+
+ ))} +
+ + ); }; diff --git a/apps/minifront/src/components/v2/root-router.tsx b/apps/minifront/src/components/v2/root-router.tsx index 25191c38c..083cd3e1b 100644 --- a/apps/minifront/src/components/v2/root-router.tsx +++ b/apps/minifront/src/components/v2/root-router.tsx @@ -5,6 +5,9 @@ import { PagePath } from '../metadata/paths'; import { DashboardLayout } from './dashboard-layout'; import { AssetsPage } from './dashboard-layout/assets-page'; import { TransactionsPage } from './dashboard-layout/transactions-page'; +import { TransferLayout } from './transfer-layout'; +import { SendPage } from './transfer-layout/send-page'; +import { ReceivePage } from './transfer-layout/receive-page'; /** @todo: Delete this helper once we switch over to the v2 layout. */ const temporarilyPrefixPathsWithV2 = (routes: RouteObject[]): RouteObject[] => @@ -47,6 +50,20 @@ export const routes: RouteObject[] = temporarilyPrefixPathsWithV2([ }, ], }, + { + path: PagePath.SEND, + element: , + children: [ + { + index: true, + element: , + }, + { + path: PagePath.RECEIVE, + element: , + }, + ], + }, ], }, ]); diff --git a/apps/minifront/src/components/v2/transfer-layout/index.tsx b/apps/minifront/src/components/v2/transfer-layout/index.tsx new file mode 100644 index 000000000..04318def8 --- /dev/null +++ b/apps/minifront/src/components/v2/transfer-layout/index.tsx @@ -0,0 +1,43 @@ +import { Card } from '@repo/ui/Card'; +import { Outlet, useNavigate } from 'react-router-dom'; +import { Grid } from '@repo/ui/Grid'; +import { Tabs } from '@repo/ui/Tabs'; +import { usePagePath } from '../../../fetchers/page-path'; +import { PagePath } from '../../metadata/paths'; +import { motion } from 'framer-motion'; + +/** @todo: Remove this function and its uses after we switch to v2 layout */ +const v2PathPrefix = (path: string) => `/v2${path}`; + +const TABS_OPTIONS = [ + { label: 'Send', value: v2PathPrefix(PagePath.SEND) }, + { label: 'Receive', value: v2PathPrefix(PagePath.RECEIVE) }, +]; + +export const TransferLayout = () => { + const pagePath = usePagePath(); + const navigate = useNavigate(); + + return ( + + + + + + + navigate(value)} + options={TABS_OPTIONS} + actionType='accent' + /> + + + + + + + + + ); +}; diff --git a/apps/minifront/src/components/v2/transfer-layout/receive-page/index.tsx b/apps/minifront/src/components/v2/transfer-layout/receive-page/index.tsx new file mode 100644 index 000000000..20f064225 --- /dev/null +++ b/apps/minifront/src/components/v2/transfer-layout/receive-page/index.tsx @@ -0,0 +1,16 @@ +import { AccountSelector } from '@repo/ui/AccountSelector'; +import { Card } from '@repo/ui/Card'; +import { FormField } from '@repo/ui/FormField'; +import { getAddrByIndex } from '../../../../fetchers/address'; + +export const ReceivePage = () => { + return ( + + + + + + + + ); +}; diff --git a/apps/minifront/src/components/v2/transfer-layout/send-page/index.tsx b/apps/minifront/src/components/v2/transfer-layout/send-page/index.tsx new file mode 100644 index 000000000..c47408917 --- /dev/null +++ b/apps/minifront/src/components/v2/transfer-layout/send-page/index.tsx @@ -0,0 +1,128 @@ +import { Card } from '@repo/ui/Card'; +import { FormField } from '@repo/ui/FormField'; +import { SegmentedControl } from '@repo/ui/SegmentedControl'; +import { TextInput } from '@repo/ui/TextInput'; +import { AllSlices } from '../../../../state'; +import { sendValidationErrors } from '../../../../state/send'; +import { FeeTier_Tier } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1/fee_pb'; +import { Button } from '@repo/ui/Button'; +import { ArrowUpFromDot } from 'lucide-react'; +import { useMemo } from 'react'; +import { useStoreShallow } from '../../../../utils/use-store-shallow'; +import { useRefreshFee } from './use-refresh-fee'; + +const sendPageSelector = (state: AllSlices) => ({ + selection: state.send.selection, + amount: state.send.amount, + recipient: state.send.recipient, + memo: state.send.memo, + fee: state.send.fee, + feeTier: state.send.feeTier, + assetFeeMetadata: state.send.assetFeeMetadata, + setAmount: state.send.setAmount, + setSelection: state.send.setSelection, + setRecipient: state.send.setRecipient, + setFeeTier: state.send.setFeeTier, + setMemo: state.send.setMemo, + sendTx: state.send.sendTx, + txInProgress: state.send.txInProgress, +}); + +const FEE_TIER_OPTIONS = [ + { + label: 'Low', + value: FeeTier_Tier.LOW, + }, + { + label: 'Medium', + value: FeeTier_Tier.MEDIUM, + }, + { + label: 'High', + value: FeeTier_Tier.HIGH, + }, +]; + +export const SendPage = () => { + const { + selection, + amount, + recipient, + memo, + feeTier, + setAmount, + setRecipient, + setFeeTier, + setMemo, + txInProgress, + + /** + * @todo: Implement form controls that use these properties: + */ + // fee, + // setSelection, + // assetFeeMetadata, + // sendTx, + } = useStoreShallow(sendPageSelector); + + useRefreshFee(); + + const validationErrors = useMemo(() => { + return sendValidationErrors(selection, amount, recipient); + }, [selection, amount, recipient]); + + const submitButtonDisabled = useMemo( + () => + !Number(amount) || + !recipient || + !!Object.values(validationErrors).find(Boolean) || + txInProgress || + !selection, + [amount, recipient, validationErrors, txInProgress, selection], + ); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/minifront/src/components/send/send-form/use-refresh-fee.ts b/apps/minifront/src/components/v2/transfer-layout/send-page/use-refresh-fee.ts similarity index 60% rename from apps/minifront/src/components/send/send-form/use-refresh-fee.ts rename to apps/minifront/src/components/v2/transfer-layout/send-page/use-refresh-fee.ts index f06312999..46778bab7 100644 --- a/apps/minifront/src/components/send/send-form/use-refresh-fee.ts +++ b/apps/minifront/src/components/v2/transfer-layout/send-page/use-refresh-fee.ts @@ -1,15 +1,24 @@ import { useCallback, useEffect, useRef } from 'react'; -import { sendSelector } from '../../../state/send'; -import { useStore } from '../../../state'; +import { AllSlices } from '../../../../state'; +import { useStoreShallow } from '../../../../utils/use-store-shallow'; const DEBOUNCE_MS = 500; +const useRefreshFeeSelector = (state: AllSlices) => ({ + amount: state.send.amount, + feeTier: state.send.feeTier, + recipient: state.send.recipient, + selection: state.send.selection, + refreshFee: state.send.refreshFee, +}); + /** * Refreshes the fee in the state when the amount, recipient, selection, or memo * changes. */ export const useRefreshFee = () => { - const { amount, feeTier, recipient, selection, refreshFee } = useStore(sendSelector); + const { amount, feeTier, recipient, selection, refreshFee } = + useStoreShallow(useRefreshFeeSelector); const timeoutId = useRef(null); const debouncedRefreshFee = useCallback(() => { diff --git a/packages/getters/src/balances-response.ts b/packages/getters/src/balances-response.ts index 22ef49275..5eca26893 100644 --- a/packages/getters/src/balances-response.ts +++ b/packages/getters/src/balances-response.ts @@ -7,10 +7,7 @@ export const getBalanceView = createGetter( (balancesResponse?: BalancesResponse) => balancesResponse?.balanceView, ); -export const getAssetIdFromBalancesResponseOptional = getBalanceView - .optional() - .pipe(getMetadata) - .pipe(getAssetId); +export const getAssetIdFromBalancesResponse = getBalanceView.pipe(getMetadata).pipe(getAssetId); export const getMetadataFromBalancesResponse = getBalanceView.pipe(getMetadata); diff --git a/packages/ui/.storybook/preview.jsx b/packages/ui/.storybook/preview.jsx index 43e5b6d8f..b17745781 100644 --- a/packages/ui/.storybook/preview.jsx +++ b/packages/ui/.storybook/preview.jsx @@ -1,16 +1,12 @@ import React, { useState } from 'react'; import globalsCssUrl from '../styles/globals.css?url'; import penumbraTheme from './penumbraTheme'; -import { ConditionalWrap } from '../src/utils/ConditionalWrap'; +import { ConditionalWrap } from '../src/ConditionalWrap'; import { PenumbraUIProvider } from '../src/PenumbraUIProvider'; import { Density } from '../src/Density'; import { Tabs } from '../src/Tabs'; import styled from 'styled-components'; -const WhiteTextWrapper = styled.div` - color: ${props => props.theme.color.text.primary}; -`; - const Column = styled.div` display: flex; flex-direction: column; @@ -66,14 +62,17 @@ const preview = { return ( - - - + ); }, ], + argTypes: { + // The `motion` prop is used throughout many Penumbra UI components for + // framer-motion settings, and shouldn't be controlled in Storybook. + motion: { control: false }, + }, parameters: { actions: { argTypesRegex: '^on[A-Z].*' }, controls: { diff --git a/packages/ui/README.md b/packages/ui/README.md index 931242727..0cef87da6 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -2,6 +2,12 @@ The Penumbra UI library is a set of UI components purpose-built for the Penumbra ecosystem. Use these components to get rendering of various Penumbra data types out of the box, and to create a UI that is consistent with other Penumbra UIs' look and feel. +## Storybook + +All Penumbra UI components (except some deprecated ones) in the latest tagged release can be found at the Penumbra UI Storybook site: https://ui.penumbra.zone/ + +To view the latest components merged to `main` (even if they are not yet in a tagged release), check out the Storybook Preview site: https://preview.ui.penumbra.zone/ + ## Set up First, install the library: diff --git a/packages/ui/package.json b/packages/ui/package.json index 21684276d..ce8472265 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "build-storybook": "storybook build", - "lint": "eslint components lib", + "lint": "eslint components lib src", "lint:fix": "eslint components lib --fix", "lint:strict": "tsc --noEmit && eslint components lib --max-warnings 0", "storybook": "storybook dev -p 6006", @@ -21,6 +21,7 @@ "./lib/utils": "./lib/utils.ts", "./postcss.config.js": "./postcss.config.js", "./styles/*": "./styles/*", + "./hooks/*": "./src/hooks/*/index.ts", "./*": "./src/*/index.tsx" }, "dependencies": { diff --git a/packages/ui/src/AccountSelector/AccountSelectorAddress.tsx b/packages/ui/src/AccountSelector/AccountSelectorAddress.tsx index 4407426ef..5f54d0c4d 100644 --- a/packages/ui/src/AccountSelector/AccountSelectorAddress.tsx +++ b/packages/ui/src/AccountSelector/AccountSelectorAddress.tsx @@ -1,36 +1,30 @@ import { Address } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb'; import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra'; -import styled from 'styled-components'; +import styled, { DefaultTheme } from 'styled-components'; import { useDensity } from '../hooks/useDensity'; import { Density } from '../types/Density'; import { CopyToClipboardButton } from '../CopyToClipboardButton'; import { Shrink0 } from '../utils/Shrink0'; -import { technical, truncate } from '../utils/typography'; +import { useAnimationDeferredValue } from '../hooks/useAnimationDeferredValue'; +import { Text } from '../Text'; -const Root = styled.div<{ $ephemeral: boolean; $loading: boolean; $density: Density }>` +const Root = styled.div<{ $density: Density }>` border: 1px solid ${props => props.theme.color.other.tonalStroke}; padding: ${props => props.theme.spacing(2)} ${props => props.theme.spacing(3)}; display: flex; gap: ${props => props.theme.spacing(2)}; - color: ${props => - props.$loading - ? props.theme.color.text.muted - : props.$ephemeral - ? props.theme.color.text.special - : props.theme.color.text.primary}; - ${props => props.$density === 'sparse' && 'word-break: break-all;'} `; -const TextWrapper = styled.div<{ $density: Density }>` +const TextWrapper = styled.div` flex-grow: 1; - - ${props => props.$density === 'compact' && truncate} - ${technical} `; +const getAddressColor = (loading: boolean, ephemeral: boolean) => (color: DefaultTheme['color']) => + loading ? color.text.muted : ephemeral ? color.text.special : color.text.primary; + export interface AccountSelectorAddressProps { address?: Address; ephemeral: boolean; @@ -43,16 +37,23 @@ export const AccountSelectorAddress = ({ loading, }: AccountSelectorAddressProps) => { const density = useDensity(); + const deferredAddress = useAnimationDeferredValue(address); return ( - - - {address ? bech32mAddress(address) : `penumbra1...`} + + + + {deferredAddress ? bech32mAddress(deferredAddress) : `penumbra1...`} + diff --git a/packages/ui/src/AccountSelector/IbcDepositToggle.tsx b/packages/ui/src/AccountSelector/IbcDepositToggle.tsx index a3fbe8a7c..7dfe8a4f5 100644 --- a/packages/ui/src/AccountSelector/IbcDepositToggle.tsx +++ b/packages/ui/src/AccountSelector/IbcDepositToggle.tsx @@ -1,6 +1,9 @@ import styled from 'styled-components'; import { Toggle } from '../Toggle'; import { Text } from '../Text'; +import { Tooltip } from '../Tooltip'; +import { Icon } from '../Icon'; +import { Info } from 'lucide-react'; const Root = styled.div` display: flex; @@ -8,16 +11,26 @@ const Root = styled.div` justify-content: space-between; `; +const Row = styled.div` + display: flex; + align-items: center; + gap: ${props => props.theme.spacing(2)}; +`; + export interface IbcDepositToggleProps { value: boolean; onChange: (value: boolean) => void; } -export const IbcDepositToggle = ({ value, onChange }: IbcDepositToggleProps) => { - return ( - - IBC Deposit - - - ); -}; +export const IbcDepositToggle = ({ value, onChange }: IbcDepositToggleProps) => ( + + + + IBC Deposit + + + + + + +); diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/DelegationTokenIcon.tsx b/packages/ui/src/AssetIcon/DelegationTokenIcon.tsx similarity index 100% rename from packages/ui/src/ValueViewComponent/AssetIcon/DelegationTokenIcon.tsx rename to packages/ui/src/AssetIcon/DelegationTokenIcon.tsx diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/UnbondingTokenIcon.tsx b/packages/ui/src/AssetIcon/UnbondingTokenIcon.tsx similarity index 100% rename from packages/ui/src/ValueViewComponent/AssetIcon/UnbondingTokenIcon.tsx rename to packages/ui/src/AssetIcon/UnbondingTokenIcon.tsx diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/index.tsx b/packages/ui/src/AssetIcon/index.tsx similarity index 88% rename from packages/ui/src/ValueViewComponent/AssetIcon/index.tsx rename to packages/ui/src/AssetIcon/index.tsx index ba005f32e..1b77b3f59 100644 --- a/packages/ui/src/ValueViewComponent/AssetIcon/index.tsx +++ b/packages/ui/src/AssetIcon/index.tsx @@ -1,14 +1,19 @@ import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; -import { Identicon } from '../../Identicon'; +import { Identicon } from '../Identicon'; import { DelegationTokenIcon } from './DelegationTokenIcon'; import { getDisplay } from '@penumbra-zone/getters/metadata'; import { assetPatterns } from '@penumbra-zone/types/assets'; import { UnbondingTokenIcon } from './UnbondingTokenIcon'; import styled from 'styled-components'; +const BorderWrapper = styled.div` + border-radius: ${props => props.theme.borderRadius.full}; + border: 1px solid ${props => props.theme.color.other.tonalStroke}; + overflow: hidden; +`; + const IconImg = styled.img` display: block; - border-radius: ${props => props.theme.borderRadius.full}; width: 24px; height: 24px; `; @@ -26,7 +31,7 @@ export const AssetIcon = ({ metadata }: AssetIcon) => { const isUnbondingToken = display ? assetPatterns.unbondingToken.matches(display) : false; return ( - <> + {icon ? ( ) : isDelegationToken ? ( @@ -41,6 +46,6 @@ export const AssetIcon = ({ metadata }: AssetIcon) => { ) : ( )} - + ); }; diff --git a/packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/Balance.tsx b/packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/Balance.tsx new file mode 100644 index 000000000..1309f7543 --- /dev/null +++ b/packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/Balance.tsx @@ -0,0 +1,31 @@ +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 new file mode 100644 index 000000000..38d18a043 --- /dev/null +++ b/packages/ui/src/AssetSelector/AssetSelectorDialogContent/MetadataOrBalancesResponse/index.tsx @@ -0,0 +1,90 @@ +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 new file mode 100644 index 000000000..6acc3364e --- /dev/null +++ b/packages/ui/src/AssetSelector/AssetSelectorDialogContent/index.tsx @@ -0,0 +1,100 @@ +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/filterMetadataOrBalancesResponseByText.test.ts b/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.test.ts new file mode 100644 index 000000000..666d6a567 --- /dev/null +++ b/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { filterMetadataOrBalancesResponseByText } from './filterMetadataOrBalancesResponseByText'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; + +const um = new Metadata({ + base: 'upenumbra', + name: 'Penumbra', + + // Including some extra text in these values to make sure we don't get false + // positives in the tests, since e.g. a symbol of `UM` is entirely contained + // in the name `Penumbra`. + symbol: 'UMSymbol', + display: 'penumbraDisplay', +}); + +const umBalance = new BalancesResponse({ + balanceView: { + valueView: { + case: 'knownAssetId', + value: { + metadata: um, + }, + }, + }, +}); + +describe('filterMetadataOrBalancesResponseByText()', () => { + describe('when the search text is empty', () => { + it('returns `true`', () => { + expect(filterMetadataOrBalancesResponseByText('')(um)).toBe(true); + }); + }); + + describe('when the search text is just whitespace', () => { + it('returns `true`', () => { + expect(filterMetadataOrBalancesResponseByText(' ')(um)).toBe(true); + }); + }); + + describe('when the value is a `Metadata`', () => { + it('returns `true` when the metadata name contains the search text', () => { + expect(filterMetadataOrBalancesResponseByText('Pen')(um)).toBe(true); + }); + + it('returns `true` when the metadata symbol contains the search text', () => { + expect(filterMetadataOrBalancesResponseByText('UMSymbol')(um)).toBe(true); + }); + + it('returns `true` when the display contains the search text', () => { + expect(filterMetadataOrBalancesResponseByText('penumbraDisplay')(um)).toBe(true); + }); + + it('returns `true` when the base contains the search text', () => { + expect(filterMetadataOrBalancesResponseByText('upenumbra')(um)).toBe(true); + }); + + it('is case-insensitive', () => { + expect(filterMetadataOrBalancesResponseByText('pen')(um)).toBe(true); + }); + }); + + describe('when the value is a `BalancesResponse`', () => { + it('returns `true` when the metadata name contains the search text', () => { + expect(filterMetadataOrBalancesResponseByText('Pen')(umBalance)).toBe(true); + }); + + it('returns `true` when the metadata symbol contains the search text', () => { + expect(filterMetadataOrBalancesResponseByText('UMSymbol')(umBalance)).toBe(true); + }); + + it('returns `true` when the display contains the search text', () => { + expect(filterMetadataOrBalancesResponseByText('penumbraDisplay')(umBalance)).toBe(true); + }); + + it('returns `true` when the base contains the search text', () => { + expect(filterMetadataOrBalancesResponseByText('upenumbra')(umBalance)).toBe(true); + }); + + it('is case-insensitive', () => { + expect(filterMetadataOrBalancesResponseByText('pen')(umBalance)).toBe(true); + }); + }); +}); diff --git a/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.ts b/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.ts new file mode 100644 index 000000000..e92944cce --- /dev/null +++ b/packages/ui/src/AssetSelector/filterMetadataOrBalancesResponseByText.ts @@ -0,0 +1,22 @@ +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 { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; + +export const filterMetadataOrBalancesResponseByText = + (textSearch: string) => + (value: Metadata | BalancesResponse): boolean => { + if (!textSearch.trim()) { + return true; + } + + const lowerCaseTextSearch = textSearch.toLocaleLowerCase(); + const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse(value); + + return ( + metadata.name.toLocaleLowerCase().includes(lowerCaseTextSearch) || + metadata.display.toLocaleLowerCase().includes(lowerCaseTextSearch) || + metadata.base.toLocaleLowerCase().includes(lowerCaseTextSearch) || + metadata.symbol.toLocaleLowerCase().includes(lowerCaseTextSearch) + ); + }; diff --git a/packages/ui/src/AssetSelector/helpers.ts b/packages/ui/src/AssetSelector/helpers.ts new file mode 100644 index 000000000..f99f904b4 --- /dev/null +++ b/packages/ui/src/AssetSelector/helpers.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 000000000..c5d89ab4a --- /dev/null +++ b/packages/ui/src/AssetSelector/index.stories.tsx @@ -0,0 +1,161 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useArgs } from '@storybook/preview-api'; + +import { AssetSelector } from '.'; +import { AssetId, 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'; + +const u8 = (length: number) => Uint8Array.from({ length }, () => Math.floor(Math.random() * 256)); + +const umAssetId = new AssetId({ inner: u8(32) }); +const osmoAssetId = new AssetId({ inner: u8(32) }); +const pizzaAssetId = new AssetId({ inner: u8(32) }); + +const um = new Metadata({ + symbol: 'UM', + name: 'Penumbra', + penumbraAssetId: umAssetId, + base: 'upenumbra', + display: 'penumbra', + denomUnits: [{ denom: 'upenumbra' }, { denom: 'penumbra', exponent: 6 }], +}); + +const osmo = new Metadata({ + symbol: 'OSMO', + name: 'Osmosis', + penumbraAssetId: osmoAssetId, + base: 'uosmo', + display: 'osmo', + denomUnits: [{ denom: 'uosmo' }, { denom: 'osmo', exponent: 6 }], +}); + +const pizza = new Metadata({ + symbol: 'PIZZA', + name: 'Pizza', + penumbraAssetId: pizzaAssetId, + base: 'upizza', + display: 'pizza', + denomUnits: [{ denom: 'upizza' }, { denom: 'pizza', exponent: 6 }], +}); + +const umBalance0 = new BalancesResponse({ + accountAddress: { + addressView: { + case: 'decoded', + value: { + index: { + account: 0, + }, + }, + }, + }, + balanceView: { + valueView: { + case: 'knownAssetId', + value: { + metadata: um, + amount: { + hi: 0n, + lo: 123_456_000n, + }, + }, + }, + }, +}); + +const osmoBalance0 = new BalancesResponse({ + accountAddress: { + addressView: { + case: 'decoded', + value: { + index: { + account: 0, + }, + }, + }, + }, + balanceView: { + valueView: { + case: 'knownAssetId', + value: { + metadata: osmo, + amount: { + hi: 0n, + lo: 456_789_000n, + }, + }, + }, + }, +}); + +const umBalance1 = new BalancesResponse({ + accountAddress: { + addressView: { + case: 'decoded', + value: { + index: { + account: 1, + }, + }, + }, + }, + balanceView: { + valueView: { + case: 'knownAssetId', + value: { + metadata: um, + amount: { + hi: 0n, + lo: 789_100_000n, + }, + }, + }, + }, +}); + +const mixedOptions: (BalancesResponse | Metadata)[] = [pizza, umBalance0, umBalance1, osmoBalance0]; +const metadataOnlyOptions: Metadata[] = [pizza, um, osmo]; + +const meta: Meta = { + component: AssetSelector, + tags: ['autodocs', '!dev', 'density'], + argTypes: { + value: { control: false }, + options: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const MixedBalancesResponsesAndMetadata: Story = { + args: { + dialogTitle: 'Transfer Assets', + value: umBalance0, + options: mixedOptions, + }, + + 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(um); + + return ( + + ); + }, +}; diff --git a/packages/ui/src/AssetSelector/index.tsx b/packages/ui/src/AssetSelector/index.tsx new file mode 100644 index 000000000..e783f76fe --- /dev/null +++ b/packages/ui/src/AssetSelector/index.tsx @@ -0,0 +1,108 @@ +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} + + 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; +`; + +export interface AssetSelectorProps { + /** + * The currently selected `Metadata` or `BalancesResponse`. + */ + value?: ValueType; + onChange: (value: ValueType) => void; + /** + * 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. + */ + options: ValueType[]; + /** The title to show above the asset selector dialog when it opens. */ + dialogTitle: string; +} + +/** + * 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 + * 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. + */ +export const AssetSelector = ({ + value, + onChange, + options, + dialogTitle, +}: AssetSelectorProps) => { + const layoutId = useId(); + const density = useDensity(); + const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse.optional()(value); + + const [isOpen, setIsOpen] = useState(false); + + const handleChange = (newValue: ValueType) => { + onChange(newValue); + setIsOpen(false); + }; + + return ( + setIsOpen(false)}> + + + + + ); +}; diff --git a/packages/ui/src/Button/index.tsx b/packages/ui/src/Button/index.tsx index a21113037..b130be98b 100644 --- a/packages/ui/src/Button/index.tsx +++ b/packages/ui/src/Button/index.tsx @@ -8,10 +8,13 @@ import { LucideIcon } from 'lucide-react'; import { Density } from '../types/Density'; import { useDensity } from '../hooks/useDensity'; import { ActionType } from '../utils/ActionType'; +import { MotionProp } from '../utils/MotionProp'; +import { motion } from 'framer-motion'; const iconOnlyAdornment = css` border-radius: ${props => props.theme.borderRadius.full}; padding: ${props => props.theme.spacing(1)}; + width: max-content; `; const sparse = css` @@ -28,6 +31,7 @@ const compact = css` padding-right: ${props => props.theme.spacing(props.$iconOnly ? 2 : 4)}; height: 32px; min-width: 32px; + width: max-content; `; const outlineColorByActionType: Record = { @@ -57,7 +61,7 @@ interface StyledButtonProps { $getBorderRadius: (theme: DefaultTheme) => string; } -const StyledButton = styled.button` +const StyledButton = styled(motion.button)` ${buttonBase} ${button} @@ -157,7 +161,7 @@ interface RegularProps { icon?: LucideIcon; } -export type ButtonProps = BaseButtonProps & (IconOnlyProps | RegularProps); +export type ButtonProps = BaseButtonProps & (IconOnlyProps | RegularProps) & MotionProp; /** * A component for all your button needs! @@ -179,6 +183,7 @@ export const Button = forwardRef( actionType = 'default', type = 'button', priority = 'primary', + motion, // needed for the Radix's `asChild` prop to work correctly // https://www.radix-ui.com/primitives/docs/guides/composition#composing-with-your-own-react-components ...props @@ -190,6 +195,7 @@ export const Button = forwardRef( return ( props.theme.spacing(4)}; `; -export interface CardProps { +export interface CardProps extends MotionProp { children?: ReactNode; /** * Which component or HTML element to render this card as. @@ -33,21 +35,6 @@ export interface CardProps { */ as?: WebTarget; title?: ReactNode; - - /** - * This will be passed on to the Framer `motion.div` wrapping the card's - * content underneath the title. - * - * @see https://www.framer.com/motion/component/##layout-animation - */ - layout?: boolean | 'position' | 'size' | 'preserve-aspect'; - /** - * This will be passed on to the Framer `motion.div` wrapping the card's - * content underneath the title. - * - * @see https://www.framer.com/motion/component/##layout-animation - */ - layoutId?: string; } /** @@ -85,14 +72,18 @@ export interface CardProps { * * ``` */ -export const Card = ({ children, as = 'section', title, layout, layoutId }: CardProps) => { +export const Card = ({ children, as = 'section', title, motion }: CardProps) => { return ( {title && {title}} - - {children} - + + {props => ( + + {children} + + )} + ); }; diff --git a/packages/ui/src/CharacterTransition/index.stories.tsx b/packages/ui/src/CharacterTransition/index.stories.tsx index d01451192..54f43237d 100644 --- a/packages/ui/src/CharacterTransition/index.stories.tsx +++ b/packages/ui/src/CharacterTransition/index.stories.tsx @@ -1,5 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react'; import { CharacterTransition } from '.'; +import styled from 'styled-components'; + +const WhiteTextWrapper = styled.div` + color: ${props => props.theme.color.text.primary}; +`; const meta: Meta = { component: CharacterTransition, @@ -16,6 +21,13 @@ const meta: Meta = { ], }, }, + decorators: [ + Story => ( + + + + ), + ], }; export default meta; diff --git a/packages/ui/src/utils/ConditionalWrap.tsx b/packages/ui/src/ConditionalWrap/index.tsx similarity index 91% rename from packages/ui/src/utils/ConditionalWrap.tsx rename to packages/ui/src/ConditionalWrap/index.tsx index 3b6f53762..ecc488695 100644 --- a/packages/ui/src/utils/ConditionalWrap.tsx +++ b/packages/ui/src/ConditionalWrap/index.tsx @@ -8,8 +8,8 @@ export interface ConditionalWrapProps { } /** - * Internal utility component to optionally wrap a React component with another - * React component, depending on a condition. + * Utility component to optionally wrap a React component with another React + * component, depending on a condition. * * @example * ```tsx diff --git a/packages/ui/src/Dialog/index.tsx b/packages/ui/src/Dialog/index.tsx index f418fc41f..26e01f70e 100644 --- a/packages/ui/src/Dialog/index.tsx +++ b/packages/ui/src/Dialog/index.tsx @@ -8,6 +8,8 @@ import { Button } from '../Button'; import { Density } from '../Density'; import { Display } from '../Display'; import { Grid } from '../Grid'; +import { MotionProp } from '../utils/MotionProp'; +import { motion } from 'framer-motion'; const Overlay = styled(RadixDialog.Overlay)` backdrop-filter: blur(${props => props.theme.blur.xs}); @@ -45,7 +47,7 @@ const DialogContent = styled.div` pointer-events: none; `; -const DialogContentCard = styled.div` +const DialogContentCard = styled(motion.div)` width: 100%; box-sizing: border-box; @@ -170,6 +172,28 @@ export type DialogProps = { * Dialog content here * * ``` + * + * ## Animating a dialog out of its trigger + * + * You can use the `motion` prop with a layout ID to make a dialog appear to + * animate out of the trigger button: + * + * ```tsx + * const layoutId = useId(); + * + * return ( + * + * + * + * + * + * ... + * + * + * ); + * ``` */ export const Dialog = ({ children, onClose, isOpen }: DialogProps) => { const isControlledComponent = isOpen !== undefined; @@ -189,7 +213,8 @@ const DialogContext = createContext<{ showCloseButton: boolean }>({ showCloseButton: true, }); -export interface DialogContentProps { +export interface DialogContentProps + extends MotionProp { children?: ReactNode; title: string; /** @@ -206,6 +231,7 @@ const Content = ({ children, title, buttonGroupProps, + motion, }: DialogContentProps) => { const { showCloseButton } = useContext(DialogContext); @@ -221,7 +247,7 @@ const Content = ({ - + diff --git a/packages/ui/src/FormField/index.tsx b/packages/ui/src/FormField/index.tsx index a2cce5085..0f444b9f8 100644 --- a/packages/ui/src/FormField/index.tsx +++ b/packages/ui/src/FormField/index.tsx @@ -11,10 +11,10 @@ const Root = styled.label` `; const HelperText = styled.div<{ $disabled: boolean }>` + ${small} + color: ${props => props.$disabled ? props.theme.color.text.muted : props.theme.color.text.secondary}; - - ${small} `; const LabelText = styled.div<{ $disabled: boolean }>` diff --git a/packages/ui/src/Icon/index.stories.ts b/packages/ui/src/Icon/index.stories.ts index 4e1533193..f7dd4137f 100644 --- a/packages/ui/src/Icon/index.stories.ts +++ b/packages/ui/src/Icon/index.stories.ts @@ -20,6 +20,6 @@ export const Basic: StoryObj = { args: { IconComponent: ArrowRightLeft, size: 'sm', - color: 'white', + color: color => color.text.primary, }, }; diff --git a/packages/ui/src/Icon/index.tsx b/packages/ui/src/Icon/index.tsx index f4c39307b..1a8f7efb0 100644 --- a/packages/ui/src/Icon/index.tsx +++ b/packages/ui/src/Icon/index.tsx @@ -1,5 +1,6 @@ import { LucideIcon } from 'lucide-react'; import { ComponentProps } from 'react'; +import { DefaultTheme, useTheme } from 'styled-components'; export type IconSize = 'sm' | 'md' | 'lg'; @@ -20,10 +21,11 @@ export interface IconProps { */ size: IconSize; /** - * The CSS color to render the icon with. If left undefined, will default to - * the parent's text color (`currentColor` in SVG terms). + * A function that takes the `color` object of `theme`, and returns a CSS color to render + * the icon with. If left undefined, will default to the parent's text color + * (`currentColor` in SVG terms). */ - color?: string; + color?: (color: DefaultTheme['color']) => string; } const PROPS_BY_SIZE: Record> = { @@ -48,9 +50,16 @@ const PROPS_BY_SIZE: Record> = { * ecosystem. * * ```tsx - * + * color.primary.main} + * /> * ``` */ -export const Icon = ({ IconComponent, size = 'sm', color }: IconProps) => ( - -); +export const Icon = ({ IconComponent, size = 'sm', color }: IconProps) => { + const theme = useTheme(); + const resolvedColor = color ? color(theme.color) : 'currentColor'; + + return ; +}; diff --git a/packages/ui/src/IsAnimatingProvider/index.tsx b/packages/ui/src/IsAnimatingProvider/index.tsx new file mode 100644 index 000000000..4e691473b --- /dev/null +++ b/packages/ui/src/IsAnimatingProvider/index.tsx @@ -0,0 +1,44 @@ +import { ReactNode, useState } from 'react'; +import { IsAnimatingContext } from '../utils/IsAnimatingContext'; + +export interface IsAnimatingProviderProps { + /** + * A function that returns the markup to render, including a framer-motion + * component. The `props` passed to this function should be spread into the + * framer-motion component. + */ + children: (props: { + onLayoutAnimationStart: VoidFunction; + onLayoutAnimationComplete: VoidFunction; + }) => ReactNode; +} + +/** + * Wrap this around a framer-motion component, if you want a descendent in the + * component tree to be able to use the `useAnimationDeferredValue()` hook. + * + * ```tsx + * + * {props => ( + * + * + * + * )} + * + * ``` + * + * `` accepts a function as its `children`, which is + * called with props to pass to the framer-motion component. + */ +export const IsAnimatingProvider = ({ children }: IsAnimatingProviderProps) => { + const [isAnimating, setIsAnimating] = useState(false); + + return ( + + {children({ + onLayoutAnimationStart: () => setIsAnimating(true), + onLayoutAnimationComplete: () => setIsAnimating(false), + })} + + ); +}; diff --git a/packages/ui/src/PenumbraUIProvider/index.tsx b/packages/ui/src/PenumbraUIProvider/index.tsx index f2f09cf00..c742d87db 100644 --- a/packages/ui/src/PenumbraUIProvider/index.tsx +++ b/packages/ui/src/PenumbraUIProvider/index.tsx @@ -1,3 +1,4 @@ +import { TooltipProvider } from '@radix-ui/react-tooltip'; import { ThemeProvider } from 'styled-components'; import { theme } from './theme'; import { PropsWithChildren } from 'react'; @@ -11,9 +12,11 @@ import { MotionConfig } from 'framer-motion'; export const PenumbraUIProvider = ({ children }: PropsWithChildren) => ( - + + - {children} + {children} + ); diff --git a/packages/ui/src/Pill/index.tsx b/packages/ui/src/Pill/index.tsx index cc1dcc6e2..ef4873f4e 100644 --- a/packages/ui/src/Pill/index.tsx +++ b/packages/ui/src/Pill/index.tsx @@ -20,6 +20,7 @@ const Root = styled.span<{ $density: Density; $priority: Priority }>` display: inline-block; max-width: 100%; + width: max-content; padding-top: ${props => props.theme.spacing(props.$density === 'sparse' ? 2 : 1)}; padding-bottom: ${props => props.theme.spacing(props.$density === 'sparse' ? 2 : 1)}; diff --git a/packages/ui/src/SegmentedControl/index.test.tsx b/packages/ui/src/SegmentedControl/index.test.tsx index 78c00b91b..138db897b 100644 --- a/packages/ui/src/SegmentedControl/index.test.tsx +++ b/packages/ui/src/SegmentedControl/index.test.tsx @@ -35,4 +35,46 @@ describe('', () => { expect(onChange).toHaveBeenCalledWith('two'); }); + + describe('when the options have non-string values', () => { + const valueOne = { toString: () => 'one' }; + const valueTwo = { toString: () => 'two' }; + const valueThree = { toString: () => 'three' }; + + const options = [ + { value: valueOne, label: 'One' }, + { value: valueTwo, label: 'Two' }, + { value: valueThree, label: 'Three' }, + ]; + + it('calls the `onClick` handler with the value of the clicked option', () => { + const { getByText } = render( + , + { wrapper: PenumbraUIProvider }, + ); + fireEvent.click(getByText('Two', { selector: ':not([aria-hidden])' })); + + expect(onChange).toHaveBeenCalledWith(valueTwo); + }); + + describe("when the options' `.toString()` methods return non-unique values", () => { + const valueOne = { toString: () => 'one' }; + const valueTwo = { toString: () => 'two' }; + const valueTwoAgain = { toString: () => 'two' }; + + const options = [ + { value: valueOne, label: 'One' }, + { value: valueTwo, label: 'Two' }, + { value: valueTwoAgain, label: 'Two again' }, + ]; + + it('throws', () => { + expect(() => + render(, { + wrapper: PenumbraUIProvider, + }), + ).toThrow('The value options passed to `` are not unique.'); + }); + }); + }); }); diff --git a/packages/ui/src/SegmentedControl/index.tsx b/packages/ui/src/SegmentedControl/index.tsx index 4b524b280..da80c7fdd 100644 --- a/packages/ui/src/SegmentedControl/index.tsx +++ b/packages/ui/src/SegmentedControl/index.tsx @@ -5,6 +5,8 @@ import { Density } from '../types/Density'; import { useDensity } from '../hooks/useDensity'; import * as RadixRadioGroup from '@radix-ui/react-radio-group'; import { useDisabled } from '../hooks/useDisabled'; +import { ToStringable } from '../utils/ToStringable'; +import { useEffect } from 'react'; const Root = styled.div` display: flex; @@ -34,17 +36,44 @@ const Segment = styled.button<{ padding-right: ${props => props.theme.spacing(props.$density === 'sparse' ? 4 : 2)}; `; -export interface Option { - value: string; +/** + * Radix's `` component only accepts strings for its values, but + * we don't want to enforce that in ``. Instead, we allow + * options to be passed whose values extend `ToStringable` (i.e., they have a + * `.toString()` method). Then, when a specific option is selected and passed to + * `onChange()`, we need to map from the string value back to the original value + * passed in the options array. + * + * To make sure this works as expected, we need to assert that each option + * value's `.toString()` method returns a unique value. That way, we can avoid a + * situation where, e.g., all the options' values return `[object Object]`, and + * the wrong object is passed to `onChange`. + */ +const assertUniqueOptions = (options: Option[]) => { + const existingOptions = new Set(); + + options.forEach(option => { + if (existingOptions.has(option.value.toString())) { + throw new Error( + 'The value options passed to `` are not unique. Please check that the result of calling `.toString()` on each of the options passed to `` is unique.', + ); + } + + existingOptions.add(option.value.toString()); + }); +}; + +export interface Option { + value: ValueType; label: string; /** Whether this individual option should be disabled. */ disabled?: boolean; } -export interface SegmentedControlProps { - value: string; - onChange: (value: string) => void; - options: Option[]; +export interface SegmentedControlProps { + value: ValueType; + onChange: (value: ValueType) => void; + options: Option[]; /** * Whether this entire control should be disabled. Note that single options * can be disabled individually by setting the `disabled` property for that @@ -74,15 +103,31 @@ export interface SegmentedControlProps { * /> * ``` */ -export const SegmentedControl = ({ value, onChange, options, disabled }: SegmentedControlProps) => { +export const SegmentedControl = ({ + value, + onChange, + options, + disabled, +}: SegmentedControlProps) => { const density = useDensity(); disabled = useDisabled(disabled); + useEffect(() => assertUniqueOptions(options), [options]); + + const handleChange = (value: string) => { + const matchingOption = options.find(option => option.value.toString() === value)!; + onChange(matchingOption.value); + }; + return ( - + {options.map(option => ( - + onChange(option.value)} $getBorderRadius={theme => theme.borderRadius.full} diff --git a/packages/ui/src/Table/index.tsx b/packages/ui/src/Table/index.tsx index 288c64bb1..281272712 100644 --- a/packages/ui/src/Table/index.tsx +++ b/packages/ui/src/Table/index.tsx @@ -3,18 +3,20 @@ import styled, { css } from 'styled-components'; import { tableHeading, tableItem } from '../utils/typography'; import { Density } from '../types/Density'; import { useDensity } from '../hooks/useDensity'; -import { ConditionalWrap } from '../utils/ConditionalWrap'; +import { ConditionalWrap } from '../ConditionalWrap'; +import { motion } from 'framer-motion'; +import { MotionProp } from '../utils/MotionProp'; const FIVE_PERCENT_OPACITY_IN_HEX = '0d'; // So named to avoid naming conflicts with `` -const StyledTable = styled.table<{ $layout?: 'fixed' | 'auto' }>` +const StyledTable = styled(motion.table)<{ $tableLayout?: 'fixed' | 'auto' }>` width: 100%; background-color: ${props => props.theme.color.neutral.contrast + FIVE_PERCENT_OPACITY_IN_HEX}; padding-left: ${props => props.theme.spacing(3)}; padding-right: ${props => props.theme.spacing(3)}; border-radius: ${props => props.theme.borderRadius.sm}; - table-layout: ${props => props.$layout ?? 'auto'}; + table-layout: ${props => props.$tableLayout ?? 'auto'}; `; const TitleAndTableWrapper = styled.div` @@ -26,12 +28,12 @@ const TitleWrapper = styled.div` padding: ${props => props.theme.spacing(3)}; `; -export interface TableProps { +export interface TableProps extends MotionProp { /** Content that will appear above the table. */ title?: ReactNode; children: ReactNode; /** Which CSS `table-layout` property to use. */ - layout?: 'fixed' | 'auto'; + tableLayout?: 'fixed' | 'auto'; } /** @@ -75,7 +77,7 @@ export interface TableProps { *
* ``` */ -export const Table = ({ title, children, layout }: TableProps) => ( +export const Table = ({ title, children, tableLayout }: TableProps) => ( ( @@ -85,7 +87,7 @@ export const Table = ({ title, children, layout }: TableProps) => ( )} > - + {children} @@ -98,8 +100,10 @@ const StyledTbody = styled.tbody``; // Needs to be a styled component for `Style const Tbody = ({ children }: PropsWithChildren) => {children}; Table.Tbody = Tbody; -const StyledTr = styled.tr``; // Needs to be a styled component for `StyledTd` below -const Tr = ({ children }: PropsWithChildren) => {children}; +const StyledTr = styled(motion.tr)``; // Needs to be a styled component for `StyledTd` below +const Tr = ({ children, motion }: PropsWithChildren) => ( + {children} +); Table.Tr = Tr; type HAlign = 'left' | 'center' | 'right'; @@ -125,7 +129,7 @@ const cell = css` ${props => props.$vAlign && `vertical-align: ${props.$vAlign};`}; `; -const StyledTh = styled.th` +const StyledTh = styled(motion.th)` border-bottom: 1px solid ${props => props.theme.color.other.tonalStroke}; text-align: left; color: ${props => props.theme.color.text.secondary}; @@ -139,26 +143,36 @@ const Th = ({ hAlign, vAlign, width, -}: PropsWithChildren<{ - colSpan?: number; - /** A CSS `width` value to use for this cell. */ - width?: string; - /** Controls the CSS `text-align` property for this cell. */ - hAlign?: HAlign; - /** Controls the CSS `vertical-align` property for this cell. */ - vAlign?: VAlign; -}>) => { + motion, +}: PropsWithChildren< + { + colSpan?: number; + /** A CSS `width` value to use for this cell. */ + width?: string; + /** Controls the CSS `text-align` property for this cell. */ + hAlign?: HAlign; + /** Controls the CSS `vertical-align` property for this cell. */ + vAlign?: VAlign; + } & MotionProp +>) => { const density = useDensity(); return ( - + {children} ); }; Table.Th = Th; -const StyledTd = styled.td` +const StyledTd = styled(motion.td)` border-bottom: 1px solid ${props => props.theme.color.other.tonalStroke}; color: ${props => props.theme.color.text.primary}; @@ -175,19 +189,29 @@ const Td = ({ hAlign, vAlign, width, -}: PropsWithChildren<{ - colSpan?: number; - /** A CSS `width` value to use for this cell. */ - width?: string; - /** Controls the CSS `text-align` property for this cell. */ - hAlign?: HAlign; - /** Controls the CSS `vertical-align` property for this cell. */ - vAlign?: VAlign; -}>) => { + motion, +}: PropsWithChildren< + { + colSpan?: number; + /** A CSS `width` value to use for this cell. */ + width?: string; + /** Controls the CSS `text-align` property for this cell. */ + hAlign?: HAlign; + /** Controls the CSS `vertical-align` property for this cell. */ + vAlign?: VAlign; + } & MotionProp +>) => { const density = useDensity(); return ( - + {children} ); diff --git a/packages/ui/src/Text/index.stories.tsx b/packages/ui/src/Text/index.stories.tsx index 8ccb34c64..c830404c4 100644 --- a/packages/ui/src/Text/index.stories.tsx +++ b/packages/ui/src/Text/index.stories.tsx @@ -20,6 +20,7 @@ const meta: Meta = { detail: { control: false }, small: { control: false }, technical: { control: false }, + detailTechnical: { control: false }, as: { options: ['span', 'div', 'h1', 'h2', 'h3', 'h4', 'p', 'main', 'section'], @@ -47,6 +48,7 @@ const OPTIONS = [ 'detail', 'small', 'technical', + 'detailTechnical', ] as const; const Option = ({ diff --git a/packages/ui/src/Text/index.tsx b/packages/ui/src/Text/index.tsx index 64c2ee660..dc6efc562 100644 --- a/packages/ui/src/Text/index.tsx +++ b/packages/ui/src/Text/index.tsx @@ -1,4 +1,4 @@ -import styled, { css, WebTarget } from 'styled-components'; +import styled, { css, DefaultTheme, WebTarget } from 'styled-components'; import { body, detail, @@ -8,6 +8,7 @@ import { h4, large, small, + detailTechnical, strong, technical, truncate, @@ -17,6 +18,7 @@ import { ReactNode } from 'react'; interface StyledProps { $truncate?: boolean; + $color?: (color: DefaultTheme['color']) => string; } const maybeTruncate = css` @@ -73,6 +75,11 @@ const Small = styled.span` ${maybeTruncate} `; +const DetailTechnical = styled.span` + ${detailTechnical} + ${maybeTruncate} +`; + const Technical = styled.span` ${technical} ${maybeTruncate} @@ -104,6 +111,7 @@ interface NeverTextTypes { strong?: never; detail?: never; small?: never; + detailTechnical?: never; technical?: never; body?: never; } @@ -192,6 +200,16 @@ type TextType = */ small: true; }) + | (Omit & { + /** + * Small monospaced text used for code, values, and other technical + * information. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + detailTechnical: true; + }) | (Omit & { /** * Monospaced text used for code, values, and other technical information. @@ -227,6 +245,11 @@ export type TextProps = TextType & { * overflow, 3) add an ellpsis when the text overflows. */ truncate?: boolean; + /** + * A function that takes the 'color' object of `theme`, and returns a CSS color to render + * the icon with. If left undefined, will default to the `text.primary` color. + */ + color?: (color: DefaultTheme['color']) => string; }; /** @@ -270,40 +293,45 @@ const omit = >( *
* ``` */ -export const Text = ({ truncate, ...props }: TextProps) => { +export const Text = ({ truncate, color, ...props }: TextProps) => { if (props.h1) { - return

; + return

; } if (props.h2) { - return

; + return

; } if (props.h3) { - return

; + return

; } if (props.h4) { - return

; + return

; } if (props.xxl) { - return ; + return ; } if (props.large) { - return ; + return ; } if (props.strong) { - return ; + return ; } if (props.detail) { - return ; + return ; } if (props.small) { - return ; + return ; + } + if (props.detailTechnical) { + return ( + + ); } if (props.technical) { - return ; + return ; } if (props.p) { - return

; + return

; } - return ; + return ; }; diff --git a/packages/ui/src/TextInput/index.stories.tsx b/packages/ui/src/TextInput/index.stories.tsx index 8aaeee455..0f0154257 100644 --- a/packages/ui/src/TextInput/index.stories.tsx +++ b/packages/ui/src/TextInput/index.stories.tsx @@ -15,6 +15,10 @@ const SampleButton = () => ( ); +const addressBookIcon = ( + color.text.primary} /> +); + const meta: Meta = { component: TextInput, tags: ['autodocs', '!dev'], @@ -22,7 +26,7 @@ const meta: Meta = { startAdornment: { options: ['Address book icon', 'None'], mapping: { - 'Address book icon': , + 'Address book icon': addressBookIcon, None: undefined, }, }, @@ -33,6 +37,8 @@ const meta: Meta = { None: undefined, }, }, + max: { control: false }, + min: { control: false }, }, }; export default meta; @@ -46,7 +52,7 @@ export const Basic: Story = { value: '', disabled: false, type: 'text', - startAdornment: , + startAdornment: addressBookIcon, endAdornment: , }, diff --git a/packages/ui/src/Tooltip/index.stories.tsx b/packages/ui/src/Tooltip/index.stories.tsx new file mode 100644 index 000000000..b51ee8765 --- /dev/null +++ b/packages/ui/src/Tooltip/index.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Tooltip } from '.'; + +const meta: Meta = { + component: Tooltip, + tags: ['autodocs', '!dev'], + argTypes: { + children: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + title: 'This is a heading', + message: + 'This is description information. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi.', + children: 'Hover over this text.', + }, +}; diff --git a/packages/ui/src/Tooltip/index.tsx b/packages/ui/src/Tooltip/index.tsx new file mode 100644 index 000000000..0ccd6fcb4 --- /dev/null +++ b/packages/ui/src/Tooltip/index.tsx @@ -0,0 +1,93 @@ +import * as RadixTooltip from '@radix-ui/react-tooltip'; +import { ReactNode } from 'react'; +import styled from 'styled-components'; +import { Text } from '../Text'; +import { buttonBase } from '../utils/button'; +import { small } from '../utils/typography'; +import { scaleIn } from '../utils/popover.ts'; + +const Content = styled(RadixTooltip.Content).attrs(props => ({ + sideOffset: props.theme.spacing(1, 'number'), +}))` + width: 200px; + padding: ${props => props.theme.spacing(2)}; + + background-color: ${props => props.theme.color.other.dialogBackground}; + border: 1px solid ${props => props.theme.color.other.tonalStroke}; + border-radius: ${props => props.theme.borderRadius.sm}; + backdrop-filter: blur(${props => props.theme.blur.xl}); + + color: ${props => props.theme.color.text.primary}; + + transform-origin: var(--radix-tooltip-content-transform-origin); + animation: ${scaleIn} 0.15s ease-out; +`; + +const Title = styled.div` + ${small} + + margin-bottom: ${props => props.theme.spacing(2)} +`; + +const Trigger = styled(RadixTooltip.Trigger)` + ${buttonBase} +`; + +export interface TooltipProps { + /** An optional title to show in larger text above the message. */ + title?: string; + /** + * A string message to show in the tooltip. Note that only strings are + * allowed; for interactive content, use a `` or a `

`. + */ + message: string; + /** + * The trigger for the tooltip. + * + * Note that the trigger will be wrapped in an HTML button element, so only pass content that can be validly nested inside a button (i.e., don't pass another button). + */ + children: ReactNode; +} + +/** + * Use this for small informational text that should appear adjacent to a piece + * of content. + * + * ```tsx + * + * Hover me + * + * ``` + * + * ## Differences between ``, ``, and ``. + * + * These three components provide similar functionality, but are meant to be + * used in distinct ways. + * + * - ``: Use dialogs for interactive or informational content that + * should take the user's attention above everything else on the page. Dialogs + * are typically opened in response to a click from a user, but may also be + * opened and closed programmatically. + * - ``: Use popovers for interactive or informational content that + * should be visually tied to a specific element on the page, such as the + * dropdown menu underneath the menu button. Popovers are typically opened in + * response to a click from a user, but may also be opened and closed + * programmatically. + * - ``: Use tooltips for plain-text informational content that + * should be visually tied to a specific element on the page. Tooltips are + * opened in response to the user hovering over that element. + */ +export const Tooltip = ({ title, message, children }: TooltipProps) => ( + + + {children} + + + + {title && {title}} + + {message} + + + +); diff --git a/packages/ui/src/ValueViewComponent/index.tsx b/packages/ui/src/ValueViewComponent/index.tsx index 10b7bb26e..dff24fb55 100644 --- a/packages/ui/src/ValueViewComponent/index.tsx +++ b/packages/ui/src/ValueViewComponent/index.tsx @@ -1,9 +1,9 @@ import { ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; -import { ConditionalWrap } from '../utils/ConditionalWrap'; +import { ConditionalWrap } from '../ConditionalWrap'; import { Pill } from '../Pill'; import { Text } from '../Text'; import styled from 'styled-components'; -import { AssetIcon } from './AssetIcon'; +import { AssetIcon } from '../AssetIcon'; import { getMetadata } from '@penumbra-zone/getters/value-view'; import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; import { Density } from '../types/Density'; diff --git a/packages/ui/src/hooks/useAnimationDeferredValue/index.test.tsx b/packages/ui/src/hooks/useAnimationDeferredValue/index.test.tsx new file mode 100644 index 000000000..70affe3b5 --- /dev/null +++ b/packages/ui/src/hooks/useAnimationDeferredValue/index.test.tsx @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { useAnimationDeferredValue } from '.'; +import { render } from '@testing-library/react'; +import { IsAnimatingContext } from '../../utils/IsAnimatingContext'; + +const MockUseAnimationDeferredValueComponent = ({ children }: { children: string }) => { + const deferredChildren = useAnimationDeferredValue(children); + + return <>{deferredChildren}; +}; + +describe('useAnimationDeferredValue()', () => { + describe('when no parent component is animating', () => { + it('returns the passed value', () => { + const { container } = render( + + + Hello, world! + + , + ); + + expect(container).toHaveTextContent('Hello, world!'); + }); + + it('immediately returns an updated passed value', () => { + const { container, rerender } = render( + + + Hello, world! + + , + ); + + expect(container).toHaveTextContent('Hello, world!'); + + rerender( + + + 'ello, poppet! + + , + ); + + expect(container).toHaveTextContent("'ello, poppet!"); + }); + }); + + describe('when a parent component is animating', () => { + it('initially returns the passed value', () => { + const { container } = render( + + + Hello, world! + + , + ); + + expect(container).toHaveTextContent('Hello, world!'); + }); + + it('does not immediately return an updated passed value', () => { + const { container, rerender } = render( + + + Hello, world! + + , + ); + + expect(container).toHaveTextContent('Hello, world!'); + + rerender( + + + 'ello, poppet! + + , + ); + + expect(container).toHaveTextContent('Hello, world!'); + }); + + it('finally returns the updated passed value once the animation is complete', () => { + const { container, rerender } = render( + + + Hello, world! + + , + ); + + expect(container).toHaveTextContent('Hello, world!'); + + rerender( + + + 'ello, poppet! + + , + ); + + expect(container).toHaveTextContent('Hello, world!'); + + rerender( + + + 'ello, poppet! + + , + ); + + expect(container).toHaveTextContent("'ello, poppet!"); + }); + }); +}); diff --git a/packages/ui/src/hooks/useAnimationDeferredValue/index.ts b/packages/ui/src/hooks/useAnimationDeferredValue/index.ts new file mode 100644 index 000000000..2017d0dac --- /dev/null +++ b/packages/ui/src/hooks/useAnimationDeferredValue/index.ts @@ -0,0 +1,100 @@ +import { useContext, useRef } from 'react'; +import { IsAnimatingContext } from '../../utils/IsAnimatingContext'; + +/** + * Use this hook just like you'd use React's `useDeferredValue()` hook, but for when + * you want to defer a value update until an in-progress animation completes. + * + * ## When to use this + * + * When using framer-motion shared layout transitions (via the + * `layout`/`layoutId` props), you may find that animations look janky because + * the components being animated are updating with new data _while the animation + * is still in progress_. + * + * For example, let's say you have a page with the user's assets, and a page + * with the user's transactions. Those pages have elements with the same + * `layoutId` so that, when the user navigates from the assets page to the + * transactions page, the elements with the shared `layoutId` will transition + * into each other when the route changes. But since you're just loading the + * user's assets and transactions from a wallet extension, which stores that + * data locally, the response will come back from the wallet so quickly that the + * transactions page will start rerendering itself with the transaction data + * that it's streaming from the wallet's response. This rerender happens _while + * the transition animation is still in progress_. As a result, the animation + * glitches, because suddenly the transactions page's layout has gotten taller + * to accommodate the new data. + * + * This hook solves that problem by letting you defer rerendering until + * animation has completed. Just like with `useDeferredValue()`, you pass a + * value to `useAnimationDeferredValue()`, and then use the returned value from + * the hook in your markup: + * + * ```tsx + * const MyComponent = ({ liveUpdatingCollection }: MyComponentProps) => { + * const deferredLiveUpdatingCollection = useAnimationDeferredValue(liveUpdatingCollection); + * + * return ( + * + * {deferredLiveUpdatingCollection.map(item => ( + *
{item.label}
+ * ))} + *
+ * ); + * } + * ``` + * + * In the above example, if `` is initially called with + * `liveUpdatingCollection` equal to an empty array (`[]`), it will initially + * render an empty `motion.div`. Then, if `liveUpdatingCollection` changes to + * have values appended to it, but an animation is in progress in a parent + * component*, `deferredLiveUpdatingCollection` won't change until the animation + * has completed. Once the animation completes, `deferredLiveUpdatingCollection` + * will be equal to the value of `liveUpdatingCollection` -- and will continue + * to be equal to it for all subsequent updates to `liveUpdatingCollection` -- + * at least, until another parent animation starts. + * + * Note that this hook doesn't delay the _loading_ of the data, but rather just + * the _rendering_ of it. Going back to the example of the assets and + * transactions pages, let's say that loading the transactions data takes 100ms, + * and rerendering the transactions page with the newly loaded data takes 20ms. + * And let's say that the transition animation from the assets page to the + * transactions page takes 125ms. + * + * First, here's the order of events if we do _not_ use + * `useAnimationDeferredValue()`: + * - 0ms: User clicks the Transactions link. The transition animation starts, + * and transaction data starts loading. + * - 100ms: The transaction data finishes loading, and the transactions page + * begins rerendering with the newly loaded data. The animation is still in + * progress. + * - 120ms: The transactions page finishes rerendering with the newly loaded + * data. This is when the glitch occurs in the animation: suddenly, the + * transactions page got taller while it was still animating. + * - 125ms: The animation finishes. + * + * Here's the order of events if we _do_ use `useAnimationDeferredValue()`: + * - 0ms: User clicks the Transactions link. The transition animation starts. + * - 100ms: Transaction data finishes loading. + * - 125ms: The transition animation completes. The transactions page begins + * rerendering with the loaded transaction data. + * - 145ms: The transaction page finishes rerendering. + * + * As you can see, the only performance cost to using a deferred value is the + * cost of _rendering_ that deferred value once it updates to the latest value. + * + * \* Note that the parent component must use `` to set + * the context value that `useAnimationDeferredValue()` reads to determine + * whether an animation is in progress. + */ +export const useAnimationDeferredValue = (value: ValueType) => { + const valueRef = useRef(value); + const isAnimating = useContext(IsAnimatingContext); + + if (isAnimating) { + return valueRef.current; + } + + valueRef.current = value; + return value; +}; diff --git a/packages/ui/src/hooks/useIsAnimating/index.tsx b/packages/ui/src/hooks/useIsAnimating/index.tsx new file mode 100644 index 000000000..d51ba9150 --- /dev/null +++ b/packages/ui/src/hooks/useIsAnimating/index.tsx @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { IsAnimatingContext } from '../../utils/IsAnimatingContext'; + +export const useIsAnimating = () => useContext(IsAnimatingContext); diff --git a/packages/ui/src/utils/IsAnimatingContext.ts b/packages/ui/src/utils/IsAnimatingContext.ts new file mode 100644 index 000000000..ce1e1ae15 --- /dev/null +++ b/packages/ui/src/utils/IsAnimatingContext.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const IsAnimatingContext = createContext(false); diff --git a/packages/ui/src/utils/MotionProp.ts b/packages/ui/src/utils/MotionProp.ts new file mode 100644 index 000000000..62203884f --- /dev/null +++ b/packages/ui/src/utils/MotionProp.ts @@ -0,0 +1,27 @@ +import { MotionProps } from 'framer-motion'; + +/** + * Utility interface for components that accept a `motion` prop, so that they + * can spread it onto a framer-motion component. + * + * Includes a JSDoc-style comment over the `motion` prop so that consumers of + * your component have documentation for the prop. + * + * @example + * ```tsx + * export interface MyComponentProps extends MotionProp { + * children?: ReactNode; + * } + * + * export const MyComponent = ({ children, motion }: MyComponentProps) => ( + * {children} + * ) + * ``` + */ +export interface MotionProp { + /** + * Any framer-motion props you wish to pass to this component to animate it or + * do shared layout transitions. + */ + motion?: MotionProps; +} diff --git a/packages/ui/src/utils/ToStringable.ts b/packages/ui/src/utils/ToStringable.ts new file mode 100644 index 000000000..e95788492 --- /dev/null +++ b/packages/ui/src/utils/ToStringable.ts @@ -0,0 +1,9 @@ +/** + * Utility interface to represent types that can be cast to string. Useful for + * e.g., accepting an array of `.toString()`-able items will be mapped over, so + * that the items can have `.toString()` called on them for the React `key` + * prop. + */ +export interface ToStringable { + toString: () => string; +} diff --git a/packages/ui/src/utils/button.ts b/packages/ui/src/utils/button.ts index e379f8210..5775c3cb7 100644 --- a/packages/ui/src/utils/button.ts +++ b/packages/ui/src/utils/button.ts @@ -7,7 +7,9 @@ export const buttonBase = css` appearance: none; background: transparent; border: none; + color: inherit; cursor: pointer; + font-family: inherit; padding: 0; `; diff --git a/packages/ui/src/utils/popover.ts b/packages/ui/src/utils/popover.ts index 7ec4cdf85..174deaa04 100644 --- a/packages/ui/src/utils/popover.ts +++ b/packages/ui/src/utils/popover.ts @@ -1,6 +1,6 @@ import styled, { keyframes } from 'styled-components'; -const scaleIn = keyframes` +export const scaleIn = keyframes` from { opacity: 0; transform: scale(0); diff --git a/packages/ui/src/utils/typography.ts b/packages/ui/src/utils/typography.ts index c7ba61c48..8519c0c97 100644 --- a/packages/ui/src/utils/typography.ts +++ b/packages/ui/src/utils/typography.ts @@ -1,4 +1,4 @@ -import { css } from 'styled-components'; +import { css, DefaultTheme } from 'styled-components'; /** * This file contains styles that are used throughout the Penumbra UI library. @@ -6,8 +6,12 @@ import { css } from 'styled-components'; * etc.), while others are base styles shared by a number of components. */ -const base = ` +const base = css<{ + $color?: (color: DefaultTheme['color']) => string; +}>` margin: 0; + color: ${props => + props.$color ? props.$color(props.theme.color) : props.theme.color.text.primary}; `; export const h1 = css` @@ -82,6 +86,15 @@ export const detail = css` line-height: ${props => props.theme.lineHeight.textXs}; `; +export const detailTechnical = css` + ${base} + + font-family: ${props => props.theme.font.mono}; + font-size: ${props => props.theme.fontSize.textXs}; + font-weight: 400; + line-height: ${props => props.theme.lineHeight.textXs}; +`; + export const small = css` ${base} @@ -137,6 +150,8 @@ export const xxl = css` `; export const button = css` + ${base} + font-family: ${props => props.theme.font.default}; font-size: ${props => props.theme.fontSize.textBase}; font-weight: 500;