Skip to content

Commit

Permalink
Make the segmented picker animated (#1167)
Browse files Browse the repository at this point in the history
* Make the segmented picker animated

* Add comment

* Extract a helpfully named component

* Hide the duplicate text

* Fix tests

* Add changeset

* Set short duration for motion transitions

* Animate top tabs
  • Loading branch information
jessepinho authored May 27, 2024
1 parent b8c8749 commit 5b80e7c
Show file tree
Hide file tree
Showing 9 changed files with 142 additions and 60 deletions.
5 changes: 5 additions & 0 deletions .changeset/unlucky-birds-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/ui': minor
---

Add animations to SegmentedPicker
4 changes: 3 additions & 1 deletion apps/minifront/src/components/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export const DashboardLayout = () => {
gradient
className='order-2 flex flex-1 flex-col p-5 md:p-4 lg:order-1 lg:col-span-2 lg:row-span-2 xl:p-5'
>
<Tabs tabs={dashboardTabs} activeTab={pathname} className='mx-auto w-full md:w-[70%]' />
<div className='mx-auto mb-4 w-full md:w-[70%]'>
<Tabs tabs={dashboardTabs} activeTab={pathname} />
</div>
<Outlet />
</Card>
<EduInfoCard
Expand Down
35 changes: 27 additions & 8 deletions apps/minifront/src/components/header/navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import { cn } from '@penumbra-zone/ui/lib/utils';
import { headerLinks } from './constants';
import { HeaderLink, headerLinks } from './constants';
import { Link } from 'react-router-dom';
import { usePagePath } from '../../fetchers/page-path';
import { AnimatePresence, motion } from 'framer-motion';
import { useId } from 'react';

const ActiveIndicator = ({ layoutId }: { layoutId: string }) => (
<motion.div
layout
layoutId={layoutId}
className='absolute inset-0 z-10 rounded-lg bg-button-gradient-secondary px-[30px] py-[10px]'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
);

const isActive = (link: HeaderLink, pathname: HeaderLink['href']) =>
link.href === pathname || link.subLinks?.includes(pathname);

export const Navbar = () => {
const pathname = usePagePath();
const layoutId = useId();

return (
<nav className='hidden max-w-xl gap-4 xl:flex xl:grow xl:justify-between'>
Expand All @@ -13,13 +29,16 @@ export const Navbar = () => {
<Link
key={link.href}
to={link.href}
className={cn(
'font-bold py-[10px] px-[30px] select-none rounded-lg',
(link.href === pathname || link.subLinks?.includes(pathname)) &&
'bg-button-gradient-secondary',
)}
className='relative select-none rounded-lg px-[30px] py-[10px] font-bold'
>
{link.label}
<AnimatePresence>
{isActive(link, pathname) && <ActiveIndicator layoutId={layoutId} />}
</AnimatePresence>

<span className='absolute inset-0 z-20 px-[30px] py-[10px]'>{link.label}</span>

{/* For layout's sake, since other elements are absolute-positioned: */}
<span className='text-transparent'>{link.label}</span>
</Link>
) : (
<div
Expand Down
5 changes: 3 additions & 2 deletions apps/minifront/src/components/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getChainId } from '../fetchers/chain-id';
import { useEffect, useState } from 'react';
import { TestnetBanner } from '@penumbra-zone/ui/components/ui/testnet-banner';
import { Status } from './Status';
import { MotionConfig } from 'framer-motion';

export interface LayoutLoaderResult {
isInstalled: boolean;
Expand All @@ -40,7 +41,7 @@ export const Layout = () => {
if (!isConnected) return <ExtensionNotConnected />;

return (
<>
<MotionConfig transition={{ duration: 0.1 }}>
<Status />
<TestnetBanner chainId={chainId} />
<HeadTag />
Expand All @@ -52,6 +53,6 @@ export const Layout = () => {
<Footer />
</div>
<Toaster />
</>
</MotionConfig>
);
};
2 changes: 1 addition & 1 deletion apps/minifront/src/components/send/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const SendLayout = () => {
<div className='hidden xl:order-1 xl:block' />
<Card
gradient
className='order-2 row-span-2 flex flex-1 flex-col p-5 md:order-1 md:p-4 xl:p-5'
className='order-2 row-span-2 flex flex-1 flex-col gap-4 p-5 md:order-1 md:p-4 xl:p-5'
>
<Tabs tabs={sendTabs} activeTab={pathname} />
<Outlet />
Expand Down
42 changes: 16 additions & 26 deletions apps/minifront/src/components/shared/tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Button } from '@penumbra-zone/ui/components/ui/button';
import { cn } from '@penumbra-zone/ui/lib/utils';
import { PagePath } from '../metadata/paths';
import { useNavigate } from 'react-router-dom';
import { SegmentedPicker } from '@penumbra-zone/ui/components/ui/segmented-picker';
import { ComponentProps } from 'react';

export interface Tab {
title: string;
Expand All @@ -15,32 +15,22 @@ interface TabsProps {
className?: string;
}

export const Tabs = ({ tabs, activeTab, className }: TabsProps) => {
export const Tabs = ({ tabs, activeTab }: TabsProps) => {
const navigate = useNavigate();
const options: ComponentProps<typeof SegmentedPicker>['options'] = tabs
.filter(tab => tab.enabled)
.map(tab => ({
label: tab.title,
value: tab.href,
}));

return (
<div
className={cn(
'inline-flex h-[52px] items-center justify-center rounded-lg bg-background px-2 mb-6 gap-3',
className,
)}
>
{tabs.map(
tab =>
tab.enabled && (
<Button
className={cn(
'w-full transition-all',
activeTab !== tab.href && ' bg-transparent text-muted-foreground',
)}
size='md'
key={tab.href}
onClick={() => navigate(tab.href)}
>
{tab.title}
</Button>
),
)}
</div>
<SegmentedPicker
value={activeTab}
onChange={value => navigate(value.toString())}
options={options}
grow
size='lg'
/>
);
};
32 changes: 20 additions & 12 deletions apps/minifront/src/components/tx-details/tx-viewer.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,45 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@penumbra-zone/ui/components/ui/tabs';
import { JsonViewer } from '@penumbra-zone/ui/components/ui/json-viewer';
import { TransactionViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/transaction';
import { TxDetailsLoaderResult } from '.';
import { TransactionInfo } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import type { Jsonified } from '@penumbra-zone/types/jsonified';
import { viewFromEmptyPerspective } from '@penumbra-zone/perspective/transaction/perspective';
import { useState } from 'react';
import { SegmentedPicker } from '@penumbra-zone/ui/components/ui/segmented-picker';

export enum TxDetailsTab {
PUBLIC = 'public',
PRIVATE = 'private',
}

const OPTIONS = [
{ label: 'Your View', value: TxDetailsTab.PRIVATE },
{ label: 'Public View', value: TxDetailsTab.PUBLIC },
];

export const TxViewer = ({ txInfo, hash }: TxDetailsLoaderResult) => {
const [option, setOption] = useState(TxDetailsTab.PRIVATE);

return (
<div>
<div className='text-xl font-bold'>Transaction View</div>
<div className='mb-8 break-all font-mono italic text-muted-foreground'>{hash}</div>
<Tabs defaultValue={TxDetailsTab.PRIVATE}>
<TabsList className='mx-auto grid w-3/4 grid-cols-2 gap-4 xl:w-[372px]'>
<TabsTrigger value={TxDetailsTab.PRIVATE}>Your View</TabsTrigger>
<TabsTrigger value={TxDetailsTab.PUBLIC}>Public View</TabsTrigger>
</TabsList>
<TabsContent value={TxDetailsTab.PRIVATE}>

<div className='mx-auto mb-4 max-w-[70%]'>
<SegmentedPicker options={OPTIONS} value={option} onChange={setOption} grow size='lg' />
</div>
{option === TxDetailsTab.PRIVATE && (
<>
<TransactionViewComponent txv={txInfo.view!} />
<div className='mt-8'>
<div className='text-xl font-bold'>Raw JSON</div>
<JsonViewer jsonObj={txInfo.toJson() as Jsonified<TransactionInfo>} />
</div>
</TabsContent>
<TabsContent value={TxDetailsTab.PUBLIC} className='mt-10'>
<TransactionViewComponent txv={viewFromEmptyPerspective(txInfo.transaction!)} />
</TabsContent>
</Tabs>
</>
)}
{option === TxDetailsTab.PUBLIC && (
<TransactionViewComponent txv={viewFromEmptyPerspective(txInfo.transaction!)} />
)}
</div>
);
};
17 changes: 13 additions & 4 deletions packages/ui/components/ui/segmented-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('<SegmentedPicker />', () => {
const { getByText } = render(
<SegmentedPicker value='one' options={options} onChange={onChange} />,
);
fireEvent.click(getByText('Two'));
fireEvent.click(getByText('Two', { selector: ':not([aria-hidden])' }));

expect(onChange).toHaveBeenCalledWith('two');
});
Expand All @@ -38,8 +38,17 @@ describe('<SegmentedPicker />', () => {
<SegmentedPicker value='one' options={options} onChange={onChange} />,
);

expect(getByText('One')).toHaveAttribute('aria-checked', 'true');
expect(getByText('Two')).toHaveAttribute('aria-checked', 'false');
expect(getByText('Three')).toHaveAttribute('aria-checked', 'false');
expect(getByText('One', { selector: ':not([aria-hidden])' }).parentElement).toHaveAttribute(
'aria-checked',
'true',
);
expect(getByText('Two', { selector: ':not([aria-hidden])' }).parentElement).toHaveAttribute(
'aria-checked',
'false',
);
expect(getByText('Three', { selector: ':not([aria-hidden])' }).parentElement).toHaveAttribute(
'aria-checked',
'false',
);
});
});
60 changes: 54 additions & 6 deletions packages/ui/components/ui/segmented-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useId } from 'react';
import { cn } from '../../lib/utils';
import { motion } from 'framer-motion';

export interface SegmentedPickerOption<ValueType> {
/**
Expand All @@ -9,6 +11,27 @@ export interface SegmentedPickerOption<ValueType> {
value: ValueType;
label: string;
}
const getRoundedClasses = (index: number, optionsLength: number, size: 'md' | 'lg') =>
cn(
index === 0 && size === 'md' && 'rounded-l-sm',
index === optionsLength - 1 && size === 'md' && 'rounded-r-sm',
index === 0 && size === 'lg' && 'rounded-l-lg',
index === optionsLength - 1 && size === 'lg' && 'rounded-r-lg',
);

const ActiveSegmentIndicator = ({
layoutId,
roundedClasses,
}: {
layoutId: string;
roundedClasses: string;
}) => (
<motion.div
layout
layoutId={layoutId}
className={cn('absolute inset-0 z-10 bg-teal', roundedClasses)}
/>
);

/**
* Renders a segmented picker where only one option can be selected at a time.
Expand All @@ -33,11 +56,19 @@ export const SegmentedPicker = <ValueType extends { toString: () => string }>({
value,
onChange,
options,
grow = false,
size = 'md',
}: {
value: ValueType;
onChange: (value: ValueType) => void;
options: SegmentedPickerOption<ValueType>[];
grow?: boolean;
size?: 'md' | 'lg';
}) => {
// Used by framer-motion to tie the active segment indicator together across
// all segments.
const layoutId = useId();

return (
<div className='flex flex-row gap-0.5' role='radiogroup'>
{options.map((option, index) => (
Expand All @@ -47,15 +78,32 @@ export const SegmentedPicker = <ValueType extends { toString: () => string }>({
aria-checked={value === option.value}
onClick={() => onChange(option.value)}
className={cn(
'px-3 py-1 font-bold cursor-pointer',
index === 0 && 'rounded-l-sm',
index === options.length - 1 && 'rounded-r-sm',
value === option.value && 'bg-teal',
'font-bold cursor-pointer relative bg-background items-center justify-center flex',
getRoundedClasses(index, options.length, size),
size === 'md' && 'text-sm px-3 h-8',
size === 'lg' && 'px-4 h-10',
value !== option.value && 'text-light-grey',
value !== option.value && 'bg-light-brown',
grow && 'grow',
)}
>
{option.label}
{value === option.value && (
<ActiveSegmentIndicator
layoutId={layoutId}
roundedClasses={getRoundedClasses(index, options.length, size)}
/>
)}

<div className='absolute inset-0 z-20 flex items-center justify-center'>
{option.label}
</div>

{/**
* Render the label again underneath the absolute-positioned label,
* since the absolute-positioned label has no effect on layout.
*/}
<span aria-hidden className='text-transparent'>
{option.label}
</span>
</div>
))}
</div>
Expand Down

0 comments on commit 5b80e7c

Please sign in to comment.