Skip to content

Commit

Permalink
feat(ui): Popover (#1696)
Browse files Browse the repository at this point in the history
* feat(ui): #prax-156: implement popover component

* feat(ui): #prax-156: add tests for Popover

* feat(ui): #prax-156: add appear animation

* chore: format

* chore: changeset

* fix(ui): #prax-156: update popover based on the comments
  • Loading branch information
VanishMax authored Aug 15, 2024
1 parent 053cc39 commit 7732f8d
Show file tree
Hide file tree
Showing 6 changed files with 335 additions and 38 deletions.
5 changes: 5 additions & 0 deletions .changeset/fresh-hornets-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@repo/ui': minor
---

Add Popover UI component
81 changes: 44 additions & 37 deletions packages/ui/src/Button/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MouseEventHandler } from 'react';
import { forwardRef, MouseEventHandler } from 'react';
import styled, { css, DefaultTheme } from 'styled-components';
import { asTransientProps } from '../utils/asTransientProps';
import { Priority, focusOutline, overlays, buttonBase } from '../utils/button';
Expand Down Expand Up @@ -168,39 +168,46 @@ export type ButtonProps = BaseButtonProps & (IconOnlyProps | RegularProps);
* (`<a />`) tag (or `<Link />`, if you're using e.g., React Router) and leave
* `onClick` undefined.
*/
export const Button = ({
children,
disabled = false,
onClick,
icon: IconComponent,
iconOnly,
actionType = 'default',
type = 'button',
priority = 'primary',
}: ButtonProps) => {
const density = useDensity();

return (
<StyledButton
{...asTransientProps({ iconOnly, density, actionType, priority })}
type={type}
disabled={disabled}
onClick={onClick}
aria-label={iconOnly ? children : undefined}
title={iconOnly ? children : undefined}
$getFocusOutlineColor={theme => theme.color.action[outlineColorByActionType[actionType]]}
$getFocusOutlineOffset={() => (iconOnly === 'adornment' ? '0px' : undefined)}
$getBorderRadius={theme =>
density === 'sparse' && iconOnly !== 'adornment'
? theme.borderRadius.sm
: theme.borderRadius.full
}
>
{IconComponent && (
<IconComponent size={density === 'sparse' && iconOnly === true ? 24 : 16} />
)}

{!iconOnly && children}
</StyledButton>
);
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
disabled = false,
onClick,
icon: IconComponent,
iconOnly,
actionType = 'default',
type = 'button',
priority = 'primary',
},
ref,
) => {
const density = useDensity();

return (
<StyledButton
{...asTransientProps({ iconOnly, density, actionType, priority })}
ref={ref}
type={type}
disabled={disabled}
onClick={onClick}
aria-label={iconOnly ? children : undefined}
title={iconOnly ? children : undefined}
$getFocusOutlineColor={theme => theme.color.action[outlineColorByActionType[actionType]]}
$getFocusOutlineOffset={() => (iconOnly === 'adornment' ? '0px' : undefined)}
$getBorderRadius={theme =>
density === 'sparse' && iconOnly !== 'adornment'
? theme.borderRadius.sm
: theme.borderRadius.full
}
>
{IconComponent && (
<IconComponent size={density === 'sparse' && iconOnly === true ? 24 : 16} />
)}

{!iconOnly && children}
</StyledButton>
);
},
);
Button.displayName = 'Button';
19 changes: 18 additions & 1 deletion packages/ui/src/PenumbraUIProvider/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,23 @@ const PALETTE = {
},
};

/**
* Call `theme.spacing(x)`, where `x` is the number of spacing units (in the
* Penumbra theme, 1 spacing unit = 4px) that you want to interpolate into your
* CSS or JavaScript. By default, returns a string with the number of pixels
* suffixed with `px` -- e.g., `theme.spacing(4)` returns `'16px'`. Pass
* `number` as the second argument to get back a number of pixels -- e.g.,
* `theme.spacing(4, 'number')` returns `16`.
*/
function spacing(spacingUnits: number, returnType?: 'string'): string;
function spacing(spacingUnits: number, returnType: 'number'): number;
function spacing(spacingUnits: number, returnType?: 'string' | 'number'): string | number {
if (returnType === 'number') {
return spacingUnits * 4;
}
return `${spacingUnits * 4}px`;
}

export const theme = {
blur: {
none: '0px',
Expand Down Expand Up @@ -238,7 +255,7 @@ export const theme = {
textSm: '1.25rem',
textXs: '1rem',
},
spacing: (spacingUnits: number) => `${spacingUnits * 4}px`,
spacing,
zIndex: {
disabledOverlay: 10,
dialogOverlay: 1000,
Expand Down
67 changes: 67 additions & 0 deletions packages/ui/src/Popover/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/react';

import { Popover } from '.';
import { Button } from '../Button';
import { ComponentType, useState } from 'react';
import { Text } from '../Text';
import styled from 'styled-components';
import { Shield } from 'lucide-react';
import { Density } from '../Density';

const Wrapper = styled.div`
display: flex;
flex-direction: column;
gap: ${props => props.theme.spacing(4)};
color: ${props => props.theme.color.text.primary};
`;

const meta: Meta<typeof Popover> = {
component: Popover,
tags: ['autodocs', '!dev'],
argTypes: {
isOpen: { control: false },
onClose: { control: false },
},
subcomponents: {
// Re: type coercion, see
// https://github.com/storybookjs/storybook/issues/23170#issuecomment-2241802787
'Popover.Content': Popover.Content as ComponentType<unknown>,
'Popover.Trigger': Popover.Trigger as ComponentType<unknown>,
},
};
export default meta;

type Story = StoryObj<typeof Popover>;

export const Basic: Story = {
render: function Render() {
const [isOpen, setIsOpen] = useState(false);

return (
<Popover isOpen={isOpen} onClose={() => setIsOpen(false)}>
<Popover.Trigger asChild>
<Button onClick={() => setIsOpen(true)}>Open popover</Button>
</Popover.Trigger>

<Popover.Content>
<Wrapper>
<Text body as='h3'>
This is a heading
</Text>
<Text small>
This is description information. Lorem ipsum dolor sit amet, consectetur adipiscing
elit. Ut et massa mi.
</Text>
<div>
<Density compact>
<Button icon={Shield} onClick={() => setIsOpen(false)}>
Action
</Button>
</Density>
</div>
</Wrapper>
</Popover.Content>
</Popover>
);
},
};
32 changes: 32 additions & 0 deletions packages/ui/src/Popover/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { fireEvent, render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Popover } from '.';
import { PenumbraUIProvider } from '../PenumbraUIProvider';

describe('<Popover />', () => {
it('opens when trigger is clicked', () => {
const { getByText, queryByText } = render(
<Popover>
<Popover.Trigger>Trigger</Popover.Trigger>
<Popover.Content>Content</Popover.Content>
</Popover>,
{ wrapper: PenumbraUIProvider },
);

expect(queryByText('Content')).toBeFalsy();
fireEvent.click(getByText('Trigger'));
expect(queryByText('Content')).toBeTruthy();
});

it('opens initially if `isOpen` is passed', () => {
const { queryByText } = render(
<Popover isOpen>
<Popover.Trigger>Trigger</Popover.Trigger>
<Popover.Content>Content</Popover.Content>
</Popover>,
{ wrapper: PenumbraUIProvider },
);

expect(queryByText('Content')).toBeTruthy();
});
});
169 changes: 169 additions & 0 deletions packages/ui/src/Popover/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { ReactNode } from 'react';
import * as RadixPopover from '@radix-ui/react-popover';
import type { PopoverContentProps as RadixPopoverContentProps } from '@radix-ui/react-popover';
import styled, { keyframes, useTheme } from 'styled-components';

const scaleIn = keyframes`
from {
opacity: 0;
transform: scale(0);
}
to {
opacity: 1;
transform: scale(1);
}
`;

const RadixContent = styled.div`
display: flex;
flex-direction: column;
gap: ${props => props.theme.spacing(4)};
width: 240px;
max-width: 320px;
padding: ${props => props.theme.spacing(3)} ${props => props.theme.spacing(2)};
background: ${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.lg});
transform-origin: var(--radix-tooltip-content-transform-origin);
animation: ${scaleIn} 0.15s ease-out;
`;

interface ControlledPopoverProps {
/**
* Whether the popover is currently open. If left `undefined`, this will be
* treated as an uncontrolled popover — that is, it will open and close based
* on user interactions rather than on state variables.
*/
isOpen: boolean;
/**
* Callback for when the user closes the popover. Should update the state
* variable being passed in via `isOpen`. If left `undefined`, users will not
* be able to close it -- that is, it will only be able to be closed programmatically
*/
onClose?: VoidFunction;
}

interface UncontrolledPopoverProps {
isOpen?: undefined;
onClose?: undefined;
}

export type PopoverProps = {
children?: ReactNode;
} & (ControlledPopoverProps | UncontrolledPopoverProps);

/**
* A popover box that appears next to the trigger element.
*
* To render a popover, compose it using a few components: `<Popover />`,
* `<Popover.Trigger />`, and `<Popover.Content />`. The latter two must be
* descendents of `<Popover />` in the component tree, and siblings to each
* other. (`<Popover.Trigger />` is optional, though — more on that in a moment.)
*
* ```tsx
* <Popover>
* <Popover.Trigger asChild>
* <Button>Open the popover</Button>
* </Popover.Trigger>
*
* <Popover.Content title="Popover title">Popover content here</Popover.Content>
* </Popover>
* ```
*
* Depending on your use case, you may want to use `<Popover />` either as a
* controlled component, or as an uncontrolled component.
*
* ## Usage as a controlled component
*
* Use `<Popover />` as a controlled component when you want to control its
* open/closed state yourself (e.g., via a state management solution like
* Zustand or Redux). You can accomplish this by passing `isOpen` and `onClose`
* props to the `<Popover />` component, and omitting `<Popover.Trigger />`:
*
* ```tsx
* <Button onClick={() => setIsOpen(true)}>Open popover</Button>
*
* <Popover isOpen={isOpen} onClose={() => setIsOpen(false)}>
* <Popover.Content title="Popover title">Popover content here</Popover.Content>
* </Popover>
* ```
*
* Note that, in the example above, the `<Button />` lives outside of the
* `<Popover />`, and there is no `<Popover.Trigger />` component rendered inside
* the `<Popover />`.
*
* ## Usage as an uncontrolled component
*
* If you want to render `<Popover />` as an uncontrolled component, don't pass
* `isOpen` or `onClose` to `<Popover />`, and make sure to include a
* `<Popover.Trigger />` component inside the `<Popover />`:
* ```tsx
* <Popover>
* <Popover.Trigger asChild>
* <Button>Open the popover</Button>
* </Popover.Trigger>
*
* <Popover.Content title="Popover title">Popover content here</Popover.Content>
* </Popover>
* ```
*/
export const Popover = ({ children, onClose, isOpen }: PopoverProps) => {
return (
<RadixPopover.Root open={isOpen} onOpenChange={value => onClose && !value && onClose()}>
{children}
</RadixPopover.Root>
);
};

export interface PopoverTriggerProps {
children: ReactNode;
/**
* Change the default rendered element for the one passed as a child, merging
* their props and behavior.
*
* Uses Radix UI's `asChild` prop under the hood.
*
* @see https://www.radix-ui.com/primitives/docs/guides/composition
*/
asChild?: boolean;
}

const Trigger = ({ children, asChild }: PopoverTriggerProps) => (
<RadixPopover.Trigger asChild={asChild}>{children}</RadixPopover.Trigger>
);
Popover.Trigger = Trigger;

export interface PopoverContentProps {
children?: ReactNode;
side?: RadixPopoverContentProps['side'];
align?: RadixPopoverContentProps['align'];
}

/**
* Popover content. Must be a child of `<Popover />`.
*
* Control the position of the Popover relative to the trigger element by passing
* `side` and `align` props.
*/
const Content = ({ children, side, align }: PopoverContentProps) => {
const theme = useTheme();

return (
<RadixPopover.Portal>
<RadixPopover.Content
sideOffset={theme.spacing(1, 'number')}
side={side}
align={align}
asChild
>
<RadixContent>{children}</RadixContent>
</RadixPopover.Content>
</RadixPopover.Portal>
);
};
Popover.Content = Content;

0 comments on commit 7732f8d

Please sign in to comment.