-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
6 changed files
with
335 additions
and
38 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@repo/ui': minor | ||
--- | ||
|
||
Add Popover UI component |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |