Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): Popover #1696

Merged
merged 6 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
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 WhiteTextWrapper = styled.div`
display: flex;
flex-direction: column;
gap: ${props => props.theme.spacing(4)};
color: ${props => props.theme.color.text.primary};
VanishMax marked this conversation as resolved.
Show resolved Hide resolved
`;

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>
<WhiteTextWrapper>
<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>
</WhiteTextWrapper>
</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();
});
});
158 changes: 158 additions & 0 deletions packages/ui/src/Popover/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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 } from 'styled-components';

const appearAnimation = (spacing: string) => keyframes`
from {
opacity: 0;
transform: translate(${spacing}, ${spacing});
}
to {
opacity: 1;
transform: translate(0, 0);
}
`;

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)};
border-radius: ${props => props.theme.borderRadius.sm};
border: 1px solid ${props => props.theme.color.other.tonalStroke};
background: ${props => props.theme.color.other.dialogBackground};
backdrop-filter: blur(${props => props.theme.blur.lg});
animation: ${props => appearAnimation(props.theme.spacing(1))} 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?: false | undefined;
VanishMax marked this conversation as resolved.
Show resolved Hide resolved
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'];
Comment on lines +143 to +144
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: This is prob fine, but for what it's worth, I'm a little wary of giving consumers too much control.

I feel like it's generally better to reduce the number of props we expose, and try to restrict usage to the designs Sam gives us. That said, I can see how users might need the side/alignment to be different for layout reasons, so this is fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, I couldn't figure out how to control the position on our side. Sam wrote in Figma that it should open "on the left side if above the trigger" and "on the right side if below the trigger". Radix doesn't allow to set it dynamically - we can choose only one option. So that's why I decided to give this level of control

}

/**
* 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) => {
return (
<RadixPopover.Portal>
<RadixPopover.Content sideOffset={4} side={side} align={align} asChild>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we have the content grow out of its origin, like I'm doing with the tooltip here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, could we use theme spacing values, rather than hard-coding the 4? You can do that either of two ways:

  1. Call useTheme(), then pass theme.spacing(1) here, instead of 4.
  2. Use styled-components' .attrs() method to set sideOffset to props.theme.spacing(1), like I'm doing here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, updated to use useTheme. And agree that we should use the same animation in similar components. It would be beneficial if you move the scaleIn in a shared file to reuse it between Tooltip, Popover, and other components

<RadixContent>{children}</RadixContent>
</RadixPopover.Content>
</RadixPopover.Portal>
);
};
Popover.Content = Content;
Loading