From dbb9e3f24bdb0d97627cff78a4e91e6b5d546074 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Wed, 14 Aug 2024 15:20:08 +0200 Subject: [PATCH 1/6] feat(ui): #prax-156: implement popover component --- packages/ui/src/Button/index.tsx | 10 +- packages/ui/src/Popover/index.stories.tsx | 64 ++++++++++ packages/ui/src/Popover/index.tsx | 139 ++++++++++++++++++++++ 3 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/Popover/index.stories.tsx create mode 100644 packages/ui/src/Popover/index.tsx diff --git a/packages/ui/src/Button/index.tsx b/packages/ui/src/Button/index.tsx index 0c0dbafc8e..4e5fc46b1c 100644 --- a/packages/ui/src/Button/index.tsx +++ b/packages/ui/src/Button/index.tsx @@ -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'; @@ -168,7 +168,7 @@ export type ButtonProps = BaseButtonProps & (IconOnlyProps | RegularProps); * (``) tag (or ``, if you're using e.g., React Router) and leave * `onClick` undefined. */ -export const Button = ({ +export const Button = forwardRef(({ children, disabled = false, onClick, @@ -177,12 +177,13 @@ export const Button = ({ actionType = 'default', type = 'button', priority = 'primary', -}: ButtonProps) => { +}, ref) => { const density = useDensity(); return ( ); -}; +}); +Button.displayName = 'Button'; diff --git a/packages/ui/src/Popover/index.stories.tsx b/packages/ui/src/Popover/index.stories.tsx new file mode 100644 index 0000000000..878611df90 --- /dev/null +++ b/packages/ui/src/Popover/index.stories.tsx @@ -0,0 +1,64 @@ +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}; +`; + +const meta: Meta = { + 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, + 'Popover.Trigger': Popover.Trigger as ComponentType, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: function Render() { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)}> + + + + + + + + This is a heading + + + This is description information. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. + +
+ + + +
+
+
+
+ ); + }, +}; diff --git a/packages/ui/src/Popover/index.tsx b/packages/ui/src/Popover/index.tsx new file mode 100644 index 0000000000..293273b6c9 --- /dev/null +++ b/packages/ui/src/Popover/index.tsx @@ -0,0 +1,139 @@ +import type { ReactNode } from 'react'; +import * as RadixPopover from '@radix-ui/react-popover'; +import styled from 'styled-components'; + +const RadixContent = styled(RadixPopover.Content)` + 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}); +`; + +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; + 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: ``, + * ``, and ``. The latter two must be + * descendents of `` in the component tree, and siblings to each + * other. (`` is optional, though — more on that in a moment.) + * + * ```tsx + * + * + * + * + * + * Popover content here + * + * ``` + * + * Depending on your use case, you may want to use `` either as a + * controlled component, or as an uncontrolled component. + * + * ## Usage as a controlled component + * + * Use `` 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 `` component, and omitting ``: + * + * ```tsx + * + * + * setIsOpen(false)}> + * Popover content here + * + * ``` + * + * Note that, in the example above, the ` + * + * + * Popover content here + * + * ``` + */ +export const Popover = ({ children, onClose, isOpen }: PopoverProps) => { + return ( + onClose && !value && onClose()}> + {children} + + ); +}; + +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) => ( + {children} +); +Popover.Trigger = Trigger; + +export interface PopoverContentProps { + children?: ReactNode; +} + +const Content = ({ + children, +}: PopoverContentProps) => { + return ( + + + {children} + + + ); +}; +Popover.Content = Content; From e42ff84b28aec937e94bba9bfb832d29a637e538 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Wed, 14 Aug 2024 16:26:44 +0200 Subject: [PATCH 2/6] feat(ui): #prax-156: add tests for Popover --- packages/ui/src/Popover/index.test.tsx | 30 ++++++++++++++++++++++++++ packages/ui/src/Popover/index.tsx | 15 +++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/Popover/index.test.tsx diff --git a/packages/ui/src/Popover/index.test.tsx b/packages/ui/src/Popover/index.test.tsx new file mode 100644 index 0000000000..dfaeeeee33 --- /dev/null +++ b/packages/ui/src/Popover/index.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Popover } from '.'; +import { PenumbraUIProvider } from '../PenumbraUIProvider'; + +describe('', () => { + it('opens when trigger is clicked', () => { + const { getByText, queryByText } = render( + + Trigger + Content + + , { wrapper: PenumbraUIProvider }); + + expect(queryByText('Content')).toBeFalsy(); + fireEvent.click(getByText('Trigger')); + expect(queryByText('Content')).toBeTruthy(); + }); + + it('opens initially if `isOpen` is passed', () => { + const { queryByText } = render( + + Trigger + Content + + , { wrapper: PenumbraUIProvider }); + + expect(queryByText('Content')).toBeTruthy(); + }); +}); diff --git a/packages/ui/src/Popover/index.tsx b/packages/ui/src/Popover/index.tsx index 293273b6c9..f16c39df09 100644 --- a/packages/ui/src/Popover/index.tsx +++ b/packages/ui/src/Popover/index.tsx @@ -1,5 +1,6 @@ -import type { ReactNode } from 'react'; +import { ReactNode} from 'react'; import * as RadixPopover from '@radix-ui/react-popover'; +import type { PopoverContentProps as RadixPopoverContentProps } from '@radix-ui/react-popover'; import styled from 'styled-components'; const RadixContent = styled(RadixPopover.Content)` @@ -123,14 +124,24 @@ Popover.Trigger = Trigger; export interface PopoverContentProps { children?: ReactNode; + side?: RadixPopoverContentProps['side']; + align?: RadixPopoverContentProps['align']; } +/** + * Popover content. Must be a child of ``. + * + * Control the position of the Popover relative to the trigger element by passing + * `side` and `align` props. + */ const Content = ({ children, + side, + align, }: PopoverContentProps) => { return ( - + {children} From d4ec7d56171ae2d97040af458ae584d6a93e02ca Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Wed, 14 Aug 2024 16:41:23 +0200 Subject: [PATCH 3/6] feat(ui): #prax-156: add appear animation --- packages/ui/src/Popover/index.tsx | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/Popover/index.tsx b/packages/ui/src/Popover/index.tsx index f16c39df09..ddb65d542a 100644 --- a/packages/ui/src/Popover/index.tsx +++ b/packages/ui/src/Popover/index.tsx @@ -1,9 +1,20 @@ import { ReactNode} from 'react'; import * as RadixPopover from '@radix-ui/react-popover'; import type { PopoverContentProps as RadixPopoverContentProps } from '@radix-ui/react-popover'; -import styled from 'styled-components'; +import styled, { keyframes } from 'styled-components'; -const RadixContent = styled(RadixPopover.Content)` +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)}; @@ -14,6 +25,7 @@ const RadixContent = styled(RadixPopover.Content)` 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 { @@ -141,9 +153,11 @@ const Content = ({ }: PopoverContentProps) => { return ( - - {children} - + + + {children} + + ); }; From e33e6f5fd124bee137adeca23d969a3b86ba55c9 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Wed, 14 Aug 2024 16:43:55 +0200 Subject: [PATCH 4/6] chore: format --- packages/ui/src/Button/index.tsx | 79 ++++++++++++----------- packages/ui/src/Popover/index.stories.tsx | 7 +- packages/ui/src/Popover/index.test.tsx | 10 +-- packages/ui/src/Popover/index.tsx | 14 ++-- 4 files changed, 57 insertions(+), 53 deletions(-) diff --git a/packages/ui/src/Button/index.tsx b/packages/ui/src/Button/index.tsx index 4e5fc46b1c..d95c84d1fd 100644 --- a/packages/ui/src/Button/index.tsx +++ b/packages/ui/src/Button/index.tsx @@ -168,41 +168,46 @@ export type ButtonProps = BaseButtonProps & (IconOnlyProps | RegularProps); * (`
`) tag (or ``, if you're using e.g., React Router) and leave * `onClick` undefined. */ -export const Button = forwardRef(({ - children, - disabled = false, - onClick, - icon: IconComponent, - iconOnly, - actionType = 'default', - type = 'button', - priority = 'primary', -}, ref) => { - const density = useDensity(); - - return ( - theme.color.action[outlineColorByActionType[actionType]]} - $getFocusOutlineOffset={() => (iconOnly === 'adornment' ? '0px' : undefined)} - $getBorderRadius={theme => - density === 'sparse' && iconOnly !== 'adornment' - ? theme.borderRadius.sm - : theme.borderRadius.full - } - > - {IconComponent && ( - - )} - - {!iconOnly && children} - - ); -}); +export const Button = forwardRef( + ( + { + children, + disabled = false, + onClick, + icon: IconComponent, + iconOnly, + actionType = 'default', + type = 'button', + priority = 'primary', + }, + ref, + ) => { + const density = useDensity(); + + return ( + theme.color.action[outlineColorByActionType[actionType]]} + $getFocusOutlineOffset={() => (iconOnly === 'adornment' ? '0px' : undefined)} + $getBorderRadius={theme => + density === 'sparse' && iconOnly !== 'adornment' + ? theme.borderRadius.sm + : theme.borderRadius.full + } + > + {IconComponent && ( + + )} + + {!iconOnly && children} + + ); + }, +); Button.displayName = 'Button'; diff --git a/packages/ui/src/Popover/index.stories.tsx b/packages/ui/src/Popover/index.stories.tsx index 878611df90..3ebb171af9 100644 --- a/packages/ui/src/Popover/index.stories.tsx +++ b/packages/ui/src/Popover/index.stories.tsx @@ -49,11 +49,14 @@ export const Basic: Story = { This is a heading - This is description information. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. + This is description information. Lorem ipsum dolor sit amet, consectetur adipiscing + elit. Ut et massa mi.
- +
diff --git a/packages/ui/src/Popover/index.test.tsx b/packages/ui/src/Popover/index.test.tsx index dfaeeeee33..e844650635 100644 --- a/packages/ui/src/Popover/index.test.tsx +++ b/packages/ui/src/Popover/index.test.tsx @@ -9,8 +9,9 @@ describe('', () => { Trigger Content - - , { wrapper: PenumbraUIProvider }); + , + { wrapper: PenumbraUIProvider }, + ); expect(queryByText('Content')).toBeFalsy(); fireEvent.click(getByText('Trigger')); @@ -22,8 +23,9 @@ describe('', () => { Trigger Content - - , { wrapper: PenumbraUIProvider }); + , + { wrapper: PenumbraUIProvider }, + ); expect(queryByText('Content')).toBeTruthy(); }); diff --git a/packages/ui/src/Popover/index.tsx b/packages/ui/src/Popover/index.tsx index ddb65d542a..89a148dd55 100644 --- a/packages/ui/src/Popover/index.tsx +++ b/packages/ui/src/Popover/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode} from 'react'; +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'; @@ -12,7 +12,7 @@ const appearAnimation = (spacing: string) => keyframes` opacity: 1; transform: translate(0, 0); } -` +`; const RadixContent = styled.div` display: flex; @@ -146,17 +146,11 @@ export interface PopoverContentProps { * Control the position of the Popover relative to the trigger element by passing * `side` and `align` props. */ -const Content = ({ - children, - side, - align, -}: PopoverContentProps) => { +const Content = ({ children, side, align }: PopoverContentProps) => { return ( - - {children} - + {children} ); From a3106d8eecdd10cc39c3d9de6b7f167b1a872878 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Wed, 14 Aug 2024 16:44:22 +0200 Subject: [PATCH 5/6] chore: changeset --- .changeset/fresh-hornets-hang.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fresh-hornets-hang.md diff --git a/.changeset/fresh-hornets-hang.md b/.changeset/fresh-hornets-hang.md new file mode 100644 index 0000000000..5f36d66905 --- /dev/null +++ b/.changeset/fresh-hornets-hang.md @@ -0,0 +1,5 @@ +--- +'@repo/ui': minor +--- + +Add Popover UI component From f8fa2c6a8dae998b5dbe4acc0465541f2c50f6e2 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Thu, 15 Aug 2024 10:41:24 +0200 Subject: [PATCH 6/6] fix(ui): #prax-156: update popover based on the comments --- packages/ui/src/PenumbraUIProvider/theme.ts | 19 +++++++++++++- packages/ui/src/Popover/index.stories.tsx | 6 ++--- packages/ui/src/Popover/index.tsx | 29 ++++++++++++++------- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/PenumbraUIProvider/theme.ts b/packages/ui/src/PenumbraUIProvider/theme.ts index 465765861f..cb50c923ca 100644 --- a/packages/ui/src/PenumbraUIProvider/theme.ts +++ b/packages/ui/src/PenumbraUIProvider/theme.ts @@ -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', @@ -238,7 +255,7 @@ export const theme = { textSm: '1.25rem', textXs: '1rem', }, - spacing: (spacingUnits: number) => `${spacingUnits * 4}px`, + spacing, zIndex: { disabledOverlay: 10, dialogOverlay: 1000, diff --git a/packages/ui/src/Popover/index.stories.tsx b/packages/ui/src/Popover/index.stories.tsx index 3ebb171af9..59b09bb2a6 100644 --- a/packages/ui/src/Popover/index.stories.tsx +++ b/packages/ui/src/Popover/index.stories.tsx @@ -8,7 +8,7 @@ import styled from 'styled-components'; import { Shield } from 'lucide-react'; import { Density } from '../Density'; -const WhiteTextWrapper = styled.div` +const Wrapper = styled.div` display: flex; flex-direction: column; gap: ${props => props.theme.spacing(4)}; @@ -44,7 +44,7 @@ export const Basic: Story = { - + This is a heading @@ -59,7 +59,7 @@ export const Basic: Story = { - + ); diff --git a/packages/ui/src/Popover/index.tsx b/packages/ui/src/Popover/index.tsx index 89a148dd55..ebe0c37f82 100644 --- a/packages/ui/src/Popover/index.tsx +++ b/packages/ui/src/Popover/index.tsx @@ -1,16 +1,16 @@ 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'; +import styled, { keyframes, useTheme } from 'styled-components'; -const appearAnimation = (spacing: string) => keyframes` +const scaleIn = keyframes` from { opacity: 0; - transform: translate(${spacing}, ${spacing}); + transform: scale(0); } to { opacity: 1; - transform: translate(0, 0); + transform: scale(1); } `; @@ -18,14 +18,18 @@ 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}; + border: 1px solid ${props => props.theme.color.other.tonalStroke}; + border-radius: ${props => props.theme.borderRadius.sm}; backdrop-filter: blur(${props => props.theme.blur.lg}); - animation: ${props => appearAnimation(props.theme.spacing(1))} 0.15s ease-out; + + transform-origin: var(--radix-tooltip-content-transform-origin); + animation: ${scaleIn} 0.15s ease-out; `; interface ControlledPopoverProps { @@ -44,7 +48,7 @@ interface ControlledPopoverProps { } interface UncontrolledPopoverProps { - isOpen?: false | undefined; + isOpen?: undefined; onClose?: undefined; } @@ -147,9 +151,16 @@ export interface PopoverContentProps { * `side` and `align` props. */ const Content = ({ children, side, align }: PopoverContentProps) => { + const theme = useTheme(); + return ( - + {children}