+
+
+ {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 (
+
+ );
+};
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;