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 diff --git a/packages/ui/src/Button/index.tsx b/packages/ui/src/Button/index.tsx index 0c0dbafc8e..d95c84d1fd 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,39 +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 = ({ - children, - disabled = false, - onClick, - icon: IconComponent, - iconOnly, - actionType = 'default', - type = 'button', - priority = 'primary', -}: ButtonProps) => { - 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/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 new file mode 100644 index 0000000000..59b09bb2a6 --- /dev/null +++ b/packages/ui/src/Popover/index.stories.tsx @@ -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 = { + 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.test.tsx b/packages/ui/src/Popover/index.test.tsx new file mode 100644 index 0000000000..e844650635 --- /dev/null +++ b/packages/ui/src/Popover/index.test.tsx @@ -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('', () => { + 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 new file mode 100644 index 0000000000..ebe0c37f82 --- /dev/null +++ b/packages/ui/src/Popover/index.tsx @@ -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: ``, + * ``, 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; + 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) => { + const theme = useTheme(); + + return ( + + + {children} + + + ); +}; +Popover.Content = Content;