Skip to content

Commit

Permalink
Add animation features to v2 components (penumbra-zone#1686)
Browse files Browse the repository at this point in the history
* Add animations to Card; create CharacterTransition

* Fix table border radius

* Add character transitions to the dashboard titles

* Add an animation to the assets table

* Remove weird opacity animations

* Add docs; rename components

* Write lots of docs

* Fix rendering bug

* Namespace the dashboard content
  • Loading branch information
jessepinho authored Aug 13, 2024
1 parent 5ce1da8 commit b2dfd5c
Show file tree
Hide file tree
Showing 9 changed files with 223 additions and 31 deletions.
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Button } from '@repo/ui/Button';
import { CharacterTransition } from '@repo/ui/CharacterTransition';
import { Dialog } from '@repo/ui/Dialog';
import { Text } from '@repo/ui/Text';
import { Info } from 'lucide-react';

export const AssetsCardTitle = () => (
<div className='flex items-center gap-2'>
Asset Balances
<CharacterTransition>Asset Balances</CharacterTransition>
<Dialog>
<Dialog.Trigger asChild>
<Button icon={Info} iconOnly='adornment'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,18 @@ export const AssetsPage = () => {
</Table.Td>

<Table.Td>
<Link to={getTradeLink(balance)}>
<Density compact>
<Button icon={ArrowRightLeft} iconOnly>
Trade
</Button>
</Density>
</Link>
<div className='h-8 w-10 overflow-hidden'>
<Link
to={getTradeLink(balance)}
className='block translate-x-full opacity-0 transition [tr:hover>td>div>&]:translate-x-0 [tr:hover>td>div>&]:opacity-100'
>
<Density compact>
<Button icon={ArrowRightLeft} iconOnly>
Trade
</Button>
</Density>
</Link>
</div>
</Table.Td>
</Table.Tr>
))}
Expand Down
27 changes: 17 additions & 10 deletions apps/minifront/src/components/v2/dashboard-layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +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';

/** @todo: Remove this function and its uses after we switch to v2 layout */
const v2PathPrefix = (path: string) => `/v2${path}`;
Expand All @@ -29,16 +30,22 @@ export const DashboardLayout = () => {
<Grid mobile={0} tablet={2} desktop={3} xl={4} />

<Grid tablet={8} desktop={6} xl={4}>
<Card title={CARD_TITLE_BY_PATH[v2PathPrefix(pagePath)]}>
<Tabs
value={v2PathPrefix(pagePath)}
onChange={value => navigate(value)}
options={TABS_OPTIONS}
actionType='accent'
/>

<Outlet />
</Card>
<LayoutGroup id='dashboard'>
<Card title={CARD_TITLE_BY_PATH[v2PathPrefix(pagePath)]} layout layoutId='main'>
<motion.div layout>
<Tabs
value={v2PathPrefix(pagePath)}
onChange={value => navigate(value)}
options={TABS_OPTIONS}
actionType='accent'
/>
</motion.div>

<LayoutGroup id='dashboardContent'>
<Outlet />
</LayoutGroup>
</Card>
</LayoutGroup>
</Grid>

<Grid mobile={0} tablet={2} desktop={3} xl={4} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Button } from '@repo/ui/Button';
import { CharacterTransition } from '@repo/ui/CharacterTransition';
import { Dialog } from '@repo/ui/Dialog';
import { Text } from '@repo/ui/Text';
import { Info } from 'lucide-react';

export const TransactionsCardTitle = () => (
<div className='flex items-center gap-2'>
Transactions List
<CharacterTransition>Transactions List</CharacterTransition>
<Dialog>
<Dialog.Trigger asChild>
<Button icon={Info} iconOnly='adornment'>
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/src/Card/Title.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import styled from 'styled-components';
import { large } from '../utils/typography';
import { ReactNode } from 'react';
import { CharacterTransition } from '../CharacterTransition';

const H2 = styled.h2`
${large};
color: ${props => props.theme.color.base.white};
padding: ${props => props.theme.spacing(3)};
`;

export interface TitleProps {
children: ReactNode;
}

export const Title = ({ children }: TitleProps) => (
<H2>
{typeof children === 'string' ? (
<CharacterTransition>{children}</CharacterTransition>
) : (
children
)}
</H2>
);
33 changes: 22 additions & 11 deletions packages/ui/src/Card/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { ReactNode } from 'react';
import styled, { WebTarget } from 'styled-components';
import { large } from '../utils/typography';
import { hexOpacity } from '../utils/hexOpacity';
import { motion } from 'framer-motion';
import { Title } from './Title';

const Root = styled.section``;

const Title = styled.h2`
${large};
color: ${props => props.theme.color.base.white};
padding: ${props => props.theme.spacing(3)};
`;

const Content = styled.div`
const Content = styled(motion.div)`
background: linear-gradient(
136deg,
${props => props.theme.color.neutral.contrast + hexOpacity(0.1)} 6.32%,
Expand All @@ -39,6 +33,21 @@ 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;
}

/**
Expand Down Expand Up @@ -76,12 +85,14 @@ export interface CardProps {
* </Card>
* ```
*/
export const Card = ({ children, as = 'section', title }: CardProps) => {
export const Card = ({ children, as = 'section', title, layout, layoutId }: CardProps) => {
return (
<Root as={as}>
{title && <Title>{title}</Title>}

<Content>{children}</Content>
<Content layout={layout} layoutId={layoutId}>
{children}
</Content>
</Root>
);
};
Expand Down
28 changes: 28 additions & 0 deletions packages/ui/src/CharacterTransition/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/react';
import { CharacterTransition } from '.';

const meta: Meta<typeof CharacterTransition> = {
component: CharacterTransition,
tags: ['autodocs', '!dev'],
argTypes: {
children: {
control: 'select',
options: [
'The quick brown fox jumps over the lazy dog.',
'Pack my box with five dozen liquor jugs.',
'The five boxing wizards jump quickly.',
'How vexingly quick daft zebras jump!',
'By Jove, my quick study of lexicography won a prize!',
],
},
},
};
export default meta;

type Story = StoryObj<typeof CharacterTransition>;

export const Basic: Story = {
args: {
children: 'The quick brown fox jumps over the lazy dog.',
},
};
114 changes: 114 additions & 0 deletions packages/ui/src/CharacterTransition/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { motion } from 'framer-motion';
import { Fragment, memo } from 'react';
import styled from 'styled-components';

/**
* Since we're splitting individual characters and wrapping them in `<span />`s,
* browsers will wrap text at whichever span happens to be at the end of a line,
* regardless of whether that span is in the middle of a word or not. So we have
* to also wrap words in spans with `white-space: nowrap` styling.
*/
const Word = styled.span`
white-space: nowrap;
`;

/**
* We need to display character spans as `inline-block`, rather than `inline`,
* because CSS `transform`s (which Framer motion uses to animate them) don't
* apply to inline elements.
*/
const Character = styled(motion.span)`
display: inline-block;
`;

export interface CharacterTransitionProps {
/**
* Note that `children` must be a string -- not any other type of React node
* -- so that it can be split into individual characters.
*/
children?: string;
}

/**
* Renders (unstyled) text that animates individual characters from their old
* positions to their new positions when the text changes.
*
* ## Using more than one `<CharacterTransition />` in the same page
*
* tl;dr: Wrap your `<CharacterTransition />` instances with framer-motion's
* `<LayoutGroup />`, and pass it an `id` prop to properly namespace layout IDs.
*
* `<CharacterTransition />` uses framer-motion's `layoutId` prop to animate the
* individual characters. The layout ID for each character is generated based on
* the character being animated, as well as its count of that character in the
* overall string. (For example, the second instance in the string of the letter
* `c` would have a layout ID of `c2`; the fourth instance of the letter `f`
* would have a layout ID of `f4`.) This ensures that, when the text changes to
* something different, characters that are shared between the previous and
* current text can animate into place.
*
* Problems can arise if you have more than one instance of
* `<CharacterTransition />` on a page at a time. You may see letters fly from
* one part of the screen to another, because their layout IDs are the same.
* Layout IDs in framer-motion are global by default, so framer-motion doesn't
* know that a `c2` in a `<CharacterTransition />` in one place shouldn't
* animate to the `c2` slot in a `<CharacterTransition />` in another place.
*
* Fortunately, framer-motion provides for this possibility via its
* `<LayoutGroup />` component. If you wrap any set of motion elements (like
* `motion.span`, which the characters in a `<CharacterTransition />` are
* wrapped in) with a `<LayoutGroup />` with its `id` property set,
* framer-motion namespaces all `layoutId`s underneath that layout group with
* the ID of the group. So, whereas the second instance of `c` would normally
* have a layout ID of `c2`, when it's wrapped in a `<LayoutGroup id="foo" />`,
* its layout ID will be namespaced under `foo`. Use `<LayoutGroup />` to ensure
* that each instance of `<CharacterTransition />` "minds its own business" and
* only animates within itself.
*
* "But why not just use `<LayoutGroup />` internally inside
* `<CharacterTransition />` so that consumers don't have to worry about it?"
* Great question. The goal is to allow consumers of `<CharacterTransition />`
* to use it in as custom of a way as possible. It may be that you won't be
* changing its `children` prop, but rather will be unmounting it entirely and
* remounting it as a child of a totally different component, but you still want
* the letters to animate to their new positions. If we gave every
* `<CharacterTransition />` its own layout ID namespace, two instances of it
* that _should_ transition between each other wouldn't be able to. Thus, we
* leave it up to the consumer to determine how to properly namespace its layout
* IDs.
*/
export const CharacterTransition = memo(({ children }: CharacterTransitionProps) => {
if (!children) {
return null;
}

const charCounts: Record<string, number> = {};

return (
/**
* Wrap the entire thing in a `<span />`, so that it is rendered as a single
* string of text. (Otherwise, a sentence in a flexbox column would have one
* word rendered per row.)
*/
<span>
{children.split(' ').map((word, index, array) => (
<Fragment key={index}>
<Word>
{word.split('').map(char => {
charCounts[char] = charCounts[char] ? charCounts[char] + 1 : 1;
const identifier = `${char}${charCounts[char]}`;

return (
<Character key={identifier} layout='position' layoutId={identifier}>
{char}
</Character>
);
})}
</Word>

{index < array.length - 1 && ' '}
</Fragment>
))}
</span>
);
});
2 changes: 1 addition & 1 deletion packages/ui/src/Table/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const StyledTable = styled.table<{ $layout?: 'fixed' | 'auto' }>`
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.lg};
border-radius: ${props => props.theme.borderRadius.sm};
table-layout: ${props => props.$layout ?? 'auto'};
`;

Expand Down

0 comments on commit b2dfd5c

Please sign in to comment.