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 `` lives outside of the
+ * ``, and there is no `` component rendered inside
+ * the ``.
+ *
+ * ## Usage as an uncontrolled component
+ *
+ * If you want to render `` as an uncontrolled component, don't pass
+ * `isOpen` or `onClose` to ``, and make sure to include a
+ * `` component inside the ``:
+
+ * ```tsx
+ *
+ *
+ *
+ *
+ *
+ * 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;