Skip to content

Commit

Permalink
feat(ui): approver
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranjamie authored and camerow committed Sep 24, 2024
1 parent 747ad76 commit ad09337
Show file tree
Hide file tree
Showing 35 changed files with 1,545 additions and 983 deletions.
1 change: 1 addition & 0 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ module.exports = {
'[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx|ls|coffee|litcoffee|coffee[.]md)$',
'theme-web',
'tsup',
'.stories.',
],
},
to: {
Expand Down
2 changes: 1 addition & 1 deletion packages/panda-preset/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
".": "./dist/preset.js"
},
"dependencies": {
"@pandacss/dev": "0.40.1"
"@pandacss/dev": "0.46.0"
},
"devDependencies": {
"@leather.io/tokens": "workspace:*",
Expand Down
32 changes: 16 additions & 16 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,38 +70,38 @@
"@microsoft/api-extractor": "7.47.6",
"@pandacss/dev": "0.46.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@storybook/addon-actions": "^8.3.2",
"@storybook/addon-docs": "^8.3.2",
"@storybook/addon-essentials": "^8.3.2",
"@storybook/addon-interactions": "^8.3.2",
"@storybook/addon-links": "^8.3.2",
"@storybook/addon-onboarding": "^8.3.2",
"@storybook/addon-actions": "8.3.2",
"@storybook/addon-docs": "8.3.2",
"@storybook/addon-essentials": "8.3.2",
"@storybook/addon-interactions": "8.3.2",
"@storybook/addon-links": "8.3.2",
"@storybook/addon-onboarding": "8.3.2",
"@storybook/addon-ondevice-actions": "7.6.20",
"@storybook/addon-ondevice-controls": "7.6.20",
"@storybook/addon-styling-webpack": "1.0.0",
"@storybook/addon-webpack5-compiler-swc": "1.0.5",
"@storybook/blocks": "^8.3.2",
"@storybook/manager-api": "^8.3.2",
"@storybook/react": "^8.3.2",
"@storybook/blocks": "8.3.2",
"@storybook/manager-api": "8.3.2",
"@storybook/react": "8.3.2",
"@storybook/react-native": "7.6.20",
"@storybook/react-webpack5": "^8.3.2",
"@storybook/test": "^8.3.2",
"@storybook/theming": "^8.3.2",
"@storybook/react-webpack5": "8.3.2",
"@storybook/test": "8.3.2",
"@storybook/theming": "8.3.2",
"@svgr/webpack": "8.1.0",
"@types/react": "18.2.79",
"@types/react-dom": "18.2.25",
"babel-preset-expo": "11.0.6",
"concurrently": "8.2.2",
"css-loader": "6.10.0",
"css-loader": "7.1.2",
"esbuild-plugin-copy": "2.1.1",
"esbuild-plugin-svgr": "2.1.0",
"eslint": "8.53.0",
"eslint-config-universe": "12.0.0",
"postcss-loader": "8.1.1",
"postcss-preset-env": "9.4.0",
"postcss-preset-env": "10.0.3",
"react-native-svg-transformer": "1.3.0",
"storybook": "^8.3.2",
"style-loader": "3.3.4",
"storybook": "8.3.2",
"style-loader": "4.0.0",
"tsconfig-paths-webpack-plugin": "4.1.0",
"tslib": "2.6.2",
"tsup": "8.1.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/.storybook-web/index.css
Original file line number Diff line number Diff line change
@@ -1 +1 @@
@layer reset, base, tokens, recipes, utilities;
@layer reset, base, tokens, recipes, utilities;
2 changes: 1 addition & 1 deletion packages/ui/src/.storybook-web/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const config: StorybookConfig = {
},
},
staticDirs: ['../assets', '../assets-web'],
stories: ['../**/*.mdx', '../**/*.web.stories.@(ts|tsx)'],
stories: ['../**/*.web.stories.@(ts|tsx)'],
swc: () => ({
jsc: {
transform: {
Expand Down
24 changes: 24 additions & 0 deletions packages/ui/src/components/animate-height/animate-height.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useRef, useState } from 'react';

import { motion } from 'framer-motion';

import { HasChildren } from '../../utils/has-children';
import { useElementHeightListener } from '../../utils/use-element-height-listener.web';

// https://github.com/framer/motion/discussions/1884#discussioncomment-5861808

export function AnimateChangeInHeight({ children }: HasChildren) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [height, setHeight] = useState<number | 'auto'>('auto');
useElementHeightListener(containerRef, height => setHeight(height));

return (
<motion.div
style={{ height, overflow: 'hidden' }}
animate={{ height }}
transition={{ duration: 0.15 }}
>
<div ref={containerRef}>{children}</div>
</motion.div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { motion, stagger, useAnimate } from 'framer-motion';
import { css } from 'leather-styles/css';
import { HasChildren } from 'src/utils/has-children';
import { useOnMount } from 'src/utils/use-on-mount';

const animationSelector = '& > *:not(.skip-animation)';

export const childElementInitialAnimationState = css({
[animationSelector]: { opacity: 0, transform: 'translateY(-16px)' },
});

const staggerMenuItems = stagger(0.06, { startDelay: 0.36 });

export function useApproverChildrenEntryAnimation() {
const [scope, animate] = useAnimate();

useOnMount(() => {
// Animation throws if there are no children
try {
animate(
animationSelector,
{ opacity: 1, transform: 'translateY(0)' },
{
duration: 0.36,
delay: staggerMenuItems,
ease: 'easeOut',
}
);
} catch (_e) {}
});

return scope;
}

interface ApproverHeaderAnimationProps extends HasChildren {
delay?: number;
}
export function ApproverHeaderAnimation({ delay = 0, ...props }: ApproverHeaderAnimationProps) {
return (
<motion.div
initial={{ x: -18, opacity: 0 }}
animate={{ x: 0, opacity: 1, transition: { duration: 0.4, delay, ease: 'easeOut' } }}
{...props}
/>
);
}

const actionsContainerDelay = 0.88;
export function ApproverActionsAnimationContainer(props: HasChildren) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { duration: 0.3, delay: actionsContainerDelay, ease: 'easeOut' },
}}
{...props}
/>
);
}

interface ApproverActionAnimationProps extends HasChildren {
index: number;
}
export function ApproverActionAnimation({ children, index }: ApproverActionAnimationProps) {
const delay = actionsContainerDelay + 0.04 + (index + 1) * 0.06;
return (
<motion.div
style={{ display: 'flex' }}
className={css({ '& > *': { flex: 1 } })}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.28, delay, ease: 'easeOut' } }}
>
{children}
</motion.div>
);
}
36 changes: 36 additions & 0 deletions packages/ui/src/components/approver/approver-context.shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createContext, useContext } from 'react';

import { useOnMount } from 'src/utils/use-on-mount';

import { ChildRegister, useRegisterChildren } from '../../utils/use-register-children';

type ApproverChildren = 'header' | 'actions' | 'advanced' | 'section' | 'subheader';

interface ApproverContext extends ChildRegister<ApproverChildren> {
isDisplayingAdvancedView: boolean;
setIsDisplayingAdvancedView(val: boolean): void;
}

const approverContext = createContext<ApproverContext | null>(null);

export const ApproverProvider = approverContext.Provider;

export function useApproverContext() {
const context = useContext(approverContext);
if (!context) throw new Error('`useApproverContext` must be used within a `ApproverProvider`');
return context;
}

export function useRegisterApproverChildren() {
return useRegisterChildren<ApproverChildren>();
}

export function useRegisterApproverChild(child: ApproverChildren) {
const { registerChild, deregisterChild, childCount } = useApproverContext();
if (childCount.actions > 1) throw new Error('Only one `Approver.Actions` is allowed');
if (childCount.advanced > 1) throw new Error('Only one `Approver.Advanced` is allowed');
useOnMount(() => {
registerChild(child);
return () => deregisterChild(child);
});
}
34 changes: 34 additions & 0 deletions packages/ui/src/components/approver/approver.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useState } from 'react';

import { HTMLStyledProps } from 'leather-styles/jsx';

import { ApproverProvider, useRegisterApproverChildren } from './approver-context.shared';
import { ApproverActions } from './components/approver-actions.web';
import { ApproverAdvanced } from './components/approver-advanced.web';
import { ApproverContainer } from './components/approver-container.web';
import { ApproverHeader } from './components/approver-header.web';
import { ApproverSection } from './components/approver-section.web';
import { ApproverStatus } from './components/approver-status.web';
import { ApproverSubheader } from './components/approver-subheader.web';

function Approver(props: HTMLStyledProps<'main'>) {
const [isDisplayingAdvancedView, setIsDisplayingAdvancedView] = useState(false);
const childRegister = useRegisterApproverChildren();

return (
<ApproverProvider
value={{ ...childRegister, isDisplayingAdvancedView, setIsDisplayingAdvancedView }}
>
<ApproverContainer {...props} />
</ApproverProvider>
);
}

Approver.Header = ApproverHeader;
Approver.Status = ApproverStatus;
Approver.Subheader = ApproverSubheader;
Approver.Section = ApproverSection;
Approver.Advanced = ApproverAdvanced;
Approver.Actions = ApproverActions;

export { Approver };
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { css } from 'leather-styles/css';
import { Flex, styled } from 'leather-styles/jsx';

import type { HasChildren } from '../../../utils/has-children';
import {
ApproverActionAnimation,
ApproverActionsAnimationContainer,
} from '../animations/approver-animation.web';
import { useRegisterApproverChild } from '../approver-context.shared';

const stretchChildrenStyles = css({ '& > *': { flex: 1 } });

interface ApproverActionsProps extends HasChildren {
actions: React.ReactNode[];
}
export function ApproverActions({ children, actions }: ApproverActionsProps) {
useRegisterApproverChild('actions');
return (
<styled.footer pos="sticky" mt="auto" bottom={0} className="skip-animation">
<ApproverActionsAnimationContainer>
<styled.div background="ink.background-primary" p="space.05">
{children}
<Flex width="100%" gap="space.04" className={stretchChildrenStyles}>
{actions.map((action, index) => (
<ApproverActionAnimation key={index} index={index}>
{action}
</ApproverActionAnimation>
))}
</Flex>
</styled.div>
</ApproverActionsAnimationContainer>
</styled.footer>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useRef } from 'react';

import { AnimatePresence, motion } from 'framer-motion';
import { Flex } from 'leather-styles/jsx';
import { AnimateChangeInHeight } from 'src/components/animate-height/animate-height.web';
import { Button } from 'src/components/button/button.web';
import { Flag } from 'src/components/flag/flag.web';
import { ChevronDownIcon } from 'src/icons/chevron-down-icon.web';
import { HasChildren } from 'src/utils/has-children';
import { getScrollParent } from 'src/utils/utils.web';

import { delay } from '@leather.io/utils';

import { useApproverContext, useRegisterApproverChild } from '../approver-context.shared';

const slightPauseForContentEnterAnimation = () => delay(120);

export function ApproverAdvanced({ children }: HasChildren) {
const { isDisplayingAdvancedView, setIsDisplayingAdvancedView } = useApproverContext();
useRegisterApproverChild('advanced');

const ref = useRef<HTMLButtonElement>(null);

async function handleToggleAdvancedView() {
setIsDisplayingAdvancedView(!isDisplayingAdvancedView);
if (ref.current && !isDisplayingAdvancedView) {
await slightPauseForContentEnterAnimation();
const scrollPosition = ref.current.offsetTop;
const scrollParent = getScrollParent(ref.current);
scrollParent?.parentElement?.scroll({ top: scrollPosition, behavior: 'smooth' });
}
}

return (
<>
<Button
ref={ref}
variant="ghost"
textAlign="left"
mt="space.03"
mb={!isDisplayingAdvancedView ? 'space.03' : 0}
px="space.05"
onClick={handleToggleAdvancedView}
>
<Flag img={<ChevronDownIcon variant="small" />} reverse>
{isDisplayingAdvancedView ? 'Hide' : 'Show'} advanced details
</Flag>
</Button>
<AnimateChangeInHeight>
<Flex justifyContent="center" flexDir="column">
<AnimatePresence>
{isDisplayingAdvancedView && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
{children}
</motion.div>
)}
</AnimatePresence>
</Flex>
</AnimateChangeInHeight>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { css } from 'leather-styles/css';
import { Flex, HTMLStyledProps, styled } from 'leather-styles/jsx';

import {
childElementInitialAnimationState,
useApproverChildrenEntryAnimation,
} from '../animations/approver-animation.web';

const applyMarginsToLastApproverSection = css({
'& .approver-section:last-child': { mb: 'space.03' },
});

export function ApproverContainer({ children, ...props }: HTMLStyledProps<'main'>) {
const scope = useApproverChildrenEntryAnimation();

return (
<styled.main
display="flex"
flexDir="column"
pos="relative"
minH="100%"
maxW="640px"
mx="auto"
className={applyMarginsToLastApproverSection}
alignItems="center"
boxShadow="0px 12px 24px 0px rgba(18, 16, 15, 0.08), 0px 4px 8px 0px rgba(18, 16, 15, 0.08), 0px 0px 2px 0px rgba(18, 16, 15, 0.08)"
{...props}
>
<Flex
className={childElementInitialAnimationState}
ref={scope}
flexDir="column"
flex={1}
background="ink.background-secondary"
>
{children}
</Flex>
</styled.main>
);
}
Loading

0 comments on commit ad09337

Please sign in to comment.