Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
69 changes: 69 additions & 0 deletions packages/design-library/bun.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion packages/design-library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
".": "./src/index.ts",
"./tokens.css": "./src/tokens.css",
"./components/*": "./src/components/*.tsx",
"./utils/*": "./src/utils/*.ts"
"./utils/*": "./src/utils/*.ts",
"./utils/portal-container": "./src/utils/portal-container.tsx"
},
"scripts": {
"storybook": "storybook dev -p 6006",
Expand All @@ -35,6 +36,7 @@
"vite": "8.0.11"
},
"dependencies": {
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-slot": "1.2.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
Expand Down
74 changes: 74 additions & 0 deletions packages/design-library/src/components/popover.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from "@storybook/react-vite";

import { Button } from "./button.js";
import { Popover } from "./popover.js";

const meta: Meta = {
title: "Components/Popover",
parameters: {
layout: "centered",
},
};

export default meta;
type Story = StoryObj;

export const Default: Story = {
render: () => (
<Popover.Root>
<Popover.Trigger asChild>
<Button>Open Popover</Button>
</Popover.Trigger>
<Popover.Content>
<div className="flex flex-col gap-2 p-2">
<p className="text-body-medium-default">Popover content</p>
<p className="text-body-medium-lighter text-[color:var(--content-secondary)]">
This is a popover with default styling.
</p>
</div>
</Popover.Content>
</Popover.Root>
),
};

export const WithCloseButton: Story = {
render: () => (
<Popover.Root>
<Popover.Trigger asChild>
<Button variant="outlined">Settings</Button>
</Popover.Trigger>
<Popover.Content side="bottom" align="start" className="w-64">
<div className="flex flex-col gap-3 p-2">
<p className="text-body-medium-default">Settings</p>
<p className="text-body-medium-lighter text-[color:var(--content-secondary)]">
Configure your preferences.
</p>
<div className="flex justify-end">
<Popover.Close asChild>
<Button variant="ghost" size="compact">Close</Button>
</Popover.Close>
</div>
</div>
</Popover.Content>
</Popover.Root>
),
};

export const Sides: Story = {
render: () => (
<div className="flex gap-4">
{(["top", "right", "bottom", "left"] as const).map((side) => (
<Popover.Root key={side}>
<Popover.Trigger asChild>
<Button variant="outlined" size="compact">{side}</Button>
</Popover.Trigger>
<Popover.Content side={side}>
<p className="p-2 text-body-small-default">
Popover on {side}
</p>
</Popover.Content>
</Popover.Root>
))}
</div>
),
};
100 changes: 100 additions & 0 deletions packages/design-library/src/components/popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as RadixPopover from "@radix-ui/react-popover";
import { type ComponentProps } from "react";

import { cn } from "../utils/cn.js";
import { usePortalContainer } from "../utils/portal-container.js";

/**
* Compound `Popover` primitive built on `@radix-ui/react-popover`.
*
* Preserves Radix's focus trap, outside-click dismissal, Escape handling,
* and `side` / `align` / `sideOffset` positioning. Content is portaled
* into the element provided by the nearest `<PortalContainerProvider>` so
* design tokens (CSS variables) resolve inside the portal. Falls back to
* `document.body` when no provider is mounted.
*
* Usage:
*
* ```tsx
* <Popover.Root>
* <Popover.Trigger asChild>
* <Button>Open</Button>
* </Popover.Trigger>
* <Popover.Content side="bottom" align="start">
* …
* <Popover.Close asChild>
* <Button variant="ghost">Close</Button>
* </Popover.Close>
* </Popover.Content>
* </Popover.Root>
* ```
*
* @see https://www.radix-ui.com/primitives/docs/components/popover
*/
const Root = RadixPopover.Root;

type TriggerProps = ComponentProps<typeof RadixPopover.Trigger>;

function Trigger(props: TriggerProps) {
return <RadixPopover.Trigger data-slot="popover-trigger" {...props} />;
}

type CloseProps = ComponentProps<typeof RadixPopover.Close>;

function Close(props: CloseProps) {
return <RadixPopover.Close data-slot="popover-close" {...props} />;
}

type AnchorProps = ComponentProps<typeof RadixPopover.Anchor>;

function Anchor(props: AnchorProps) {
return <RadixPopover.Anchor data-slot="popover-anchor" {...props} />;
}

type ContentProps = ComponentProps<typeof RadixPopover.Content>;

function Content({
className,
children,
sideOffset = 6,
align = "center",
ref,
...rest
}: ContentProps) {
const portalContainer = usePortalContainer();
return (
<RadixPopover.Portal container={portalContainer ?? undefined}>
<RadixPopover.Content
ref={ref}
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 rounded-lg bg-[var(--surface-lift)] p-2 shadow-[var(--shadow-popover)] outline-none",
"text-[color:var(--content-default)]",
"data-[state=open]:animate-[popoverIn_120ms_ease-out]",
className,
)}
{...rest}
>
{children}
</RadixPopover.Content>
</RadixPopover.Portal>
);
}

/**
* Convenience namespace so callers write `<Popover.Root>` /
* `<Popover.Trigger>` / `<Popover.Content>` / `<Popover.Close>` without
* importing each symbol separately. Mirrors Radix's own compound-component
* ergonomics.
*/
const Popover = {
Root,
Trigger,
Content,
Close,
Anchor,
};

export { Popover, type ContentProps as PopoverContentProps };
9 changes: 9 additions & 0 deletions packages/design-library/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,13 @@ export {
type TypographyVariant,
type TypographyAs,
} from "./components/typography.js";
export {
Popover,
type PopoverContentProps,
} from "./components/popover.js";
export { cn } from "./utils/cn.js";
export {
PortalContainerProvider,
usePortalContainer,
type PortalContainerProviderProps,
} from "./utils/portal-container.js";
18 changes: 18 additions & 0 deletions packages/design-library/src/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@
/* Aux */
--aux-white: #FFFFFF;

/* Shadow */
--shadow-popover: 0 1px 3px 0 rgba(0, 0, 0, 0.10), 0 4px 12px 0 rgba(0, 0, 0, 0.10);

/* Derived */
--ring: color-mix(in srgb, var(--system-positive-strong) 30%, transparent);
--border-overlay: var(--border-disabled);
Expand Down Expand Up @@ -181,6 +184,9 @@
/* Aux */
--aux-white: #FFFFFF;

/* Shadow */
--shadow-popover: 0 1px 3px 0 rgba(0, 0, 0, 0.25), 0 4px 12px 0 rgba(0, 0, 0, 0.25);

/* Derived */
--border-overlay: var(--border-hover);
--ghost-hover: var(--border-base);
Expand Down Expand Up @@ -237,6 +243,9 @@
/* Aux */
--aux-white: #FFFFFF;

/* Shadow */
--shadow-popover: 0 1px 3px 0 rgba(0, 0, 0, 0.25), 0 4px 12px 0 rgba(0, 0, 0, 0.25);

/* Derived */
--border-overlay: var(--border-hover);
--ghost-hover: var(--border-base);
Expand Down Expand Up @@ -381,4 +390,13 @@
line-height: var(--text-chat-line-height);
}

/* ---------------------------------------------------------------------------
* Overlay animation keyframes.
* --------------------------------------------------------------------------- */

@keyframes popoverIn {
0% { opacity: 0; transform: translateY(-4px) scale(0.98); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}


83 changes: 83 additions & 0 deletions packages/design-library/src/utils/portal-container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { createContext, useContext, type ReactNode } from "react";

/**
* Context for configuring where portaled content (popovers, modals, menus,
* dropdowns, bottom sheets, etc.) renders in the DOM.
*
* Design systems need portal containers to ensure overlays render inside a
* theme-scoped element so CSS variables resolve correctly. Without this,
* portaling to `document.body` loses access to scoped design tokens.
*
* **Pattern precedent:** Chakra UI (`PortalManager`), Blueprint
* (`PortalProvider`), react-md (`PortalContainerProvider`), and Ark UI
* (`EnvironmentProvider`) all use this same context-based approach.
*
* @see https://www.radix-ui.com/primitives/docs/utilities/portal
* @see https://github.com/palantir/blueprint/pull/6260
* @see https://react-md.dev/components/portal-container-provider
*/
const PortalContainerContext = createContext<HTMLElement | null>(null);

export interface PortalContainerProviderProps {
/** The DOM element that portaled content should render into. */
container: HTMLElement | null;
children: ReactNode;
}

/**
* Provides a portal target element to all descendant design library
* components that render overlays (Popover, Modal, Menu, etc.).
*
* Mount this at the top of your app shell, passing the element that has
* your theme's CSS variables in scope. Overlay components read this
* context internally and fall back to `document.body` when no provider
* is mounted.
*
* Nestable — an inner provider overrides the outer one for its subtree,
* which is useful for rendering overlays inside dialogs or shadow DOM.
*
* ```tsx
* function AppShell({ children }: { children: ReactNode }) {
* const ref = useRef<HTMLDivElement>(null);
* const [container, setContainer] = useState<HTMLElement | null>(null);
* useEffect(() => { setContainer(ref.current); }, []);
*
* return (
* <div ref={ref} className="app-root">
* <PortalContainerProvider container={container}>
* {children}
* </PortalContainerProvider>
* </div>
* );
* }
* ```
*/
function PortalContainerProvider({
container,
children,
}: PortalContainerProviderProps) {
return (
<PortalContainerContext value={container}>
{children}
</PortalContainerContext>
);
}

/**
* Returns the nearest portal container element, or `null` when called
* outside a `<PortalContainerProvider>`.
*
* Overlay components pass the result to Radix's `Portal` `container`
* prop (coerced to `undefined` so Radix falls back to `document.body`
* when no provider is mounted):
*
* ```tsx
* const container = usePortalContainer();
* <RadixPopover.Portal container={container ?? undefined}>
* ```
*/
function usePortalContainer(): HTMLElement | null {
return useContext(PortalContainerContext);
}

export { PortalContainerProvider, usePortalContainer };
Loading