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
15 changes: 15 additions & 0 deletions packages/demo/src/pages/getting-started/usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ import { ThemeProvider } from "@eqtylab/equality";
</ThemeProvider>;
```

### Portalled surfaces

Tooltips, popovers, selects, dialogs, sheets, drawers, and dropdown menus render into a portal that `<ThemeProvider />` supplies automatically, so they stay inside the themed subtree. Two cases need you to provide that container yourself, via the `portalContainer` prop:

- **Shadow DOM** — the default document lookup can't see into a shadow root, so portalled surfaces escape the boundary and render unstyled.
- **More than one theme root on a page** — otherwise the second root's portals can land inside the first one's container.

```tsx
<ThemeProvider portalContainer={myElement}>
<YourApp />
</ThemeProvider>
```

Pass `portalContainer={null}` to portal to `document.body`. To supply a container without rendering a `<ThemeProvider />` at all, use the exported `<PortalContainerProvider container={myElement} />`.

## Use components

Each component includes its own scoped `.module.css` file — no need to import a global stylesheet.
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/alert-dialog/alert-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { type VariantProps } from 'class-variance-authority';

import styles from '@/components/alert-dialog/alert-dialog.module.css';
import { buttonVariants } from '@/components/button/button';
import { cn, getThemeProviderRoot } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { usePortalContainer } from '@/theme/portal-container';

const AlertDialog = AlertDialogPrimitive.Root;

const AlertDialogTrigger = AlertDialogPrimitive.Trigger;

const AlertDialogPortal = ({ children }: { children: React.ReactNode }) => (
<AlertDialogPrimitive.Portal container={getThemeProviderRoot()}>
<AlertDialogPrimitive.Portal container={usePortalContainer()}>
{children}
</AlertDialogPrimitive.Portal>
);
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';

import styles from '@/components/dialog/dialog.module.css';
import { IconButton } from '@/components/icon-button/icon-button';
import { cn, getThemeProviderRoot } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { usePortalContainer } from '@/theme/portal-container';

const Dialog = DialogPrimitive.Root;

const DialogTrigger = DialogPrimitive.Trigger;

const DialogPortal = ({ children }: { children: React.ReactNode }) => (
<DialogPrimitive.Portal container={getThemeProviderRoot()}>{children}</DialogPrimitive.Portal>
<DialogPrimitive.Portal container={usePortalContainer()}>{children}</DialogPrimitive.Portal>
);

const DialogClose = DialogPrimitive.Close;
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/drawer/drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';

import styles from '@/components/drawer/drawer.module.css';
import { cn, getThemeProviderRoot } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { usePortalContainer } from '@/theme/portal-container';

const Drawer = ({
shouldScaleBackground = true,
Expand All @@ -15,7 +16,7 @@ Drawer.displayName = 'Drawer';
const DrawerTrigger = DrawerPrimitive.Trigger;

const DrawerPortal = ({ children }: { children: React.ReactNode }) => (
<DrawerPrimitive.Portal container={getThemeProviderRoot()}>{children}</DrawerPrimitive.Portal>
<DrawerPrimitive.Portal container={usePortalContainer()}>{children}</DrawerPrimitive.Portal>
);

const DrawerClose = DrawerPrimitive.Close;
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/dropdown-menu/dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle, Search } from 'lucide-react';

import styles from '@/components/dropdown-menu/dropdown-menu.module.css';
import { cn, getThemeProviderRoot } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { usePortalContainer } from '@/theme/portal-container';

const CheckIcon = Check as React.ComponentType<{ className?: string }>;
const ChevronRightIcon = ChevronRight as React.ComponentType<{ className?: string }>;
Expand Down Expand Up @@ -260,7 +261,7 @@ const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;

const DropdownMenuPortal = ({ children }: { children: React.ReactNode }) => (
<DropdownMenuPrimitive.Portal container={getThemeProviderRoot()}>
<DropdownMenuPrimitive.Portal container={usePortalContainer()}>
{children}
</DropdownMenuPrimitive.Portal>
);
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';

import styles from '@/components/popover/popover.module.css';
import { cn, getThemeProviderRoot } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { usePortalContainer } from '@/theme/portal-container';

const Popover = PopoverPrimitive.Root;

const PopoverTrigger = PopoverPrimitive.Trigger;

const PopoverPortal = ({ children }: { children: React.ReactNode }) => (
<PopoverPrimitive.Portal container={getThemeProviderRoot()}>{children}</PopoverPrimitive.Portal>
<PopoverPrimitive.Portal container={usePortalContainer()}>{children}</PopoverPrimitive.Portal>
);

const PopoverContent = React.forwardRef<
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/select/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { Check, ChevronDown, ChevronUp } from 'lucide-react';

import styles from '@/components/select/select.module.css';
import { ELEVATION, generateElevationVariants } from '@/lib/elevations';
import { cn, getThemeProviderRoot } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { usePortalContainer } from '@/theme/portal-container';

const CheckIcon = Check as React.ComponentType<{ className?: string }>;
const ChevronDownIcon = ChevronDown as React.ComponentType<{ className?: string }>;
Expand Down Expand Up @@ -59,7 +60,7 @@ const SelectScrollDownButton = React.forwardRef<
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;

const SelectPortal = ({ children }: { children: React.ReactNode }) => (
<SelectPrimitive.Portal container={getThemeProviderRoot()}>{children}</SelectPrimitive.Portal>
<SelectPrimitive.Portal container={usePortalContainer()}>{children}</SelectPrimitive.Portal>
);

const selectContentElevationVariants = generateElevationVariants(
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/sheet/sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { cva, type VariantProps } from 'class-variance-authority';

import { IconButton } from '@/components/icon-button/icon-button';
import styles from '@/components/sheet/sheet.module.css';
import { cn, getThemeProviderRoot } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { usePortalContainer } from '@/theme/portal-container';

const Sheet = SheetPrimitive.Root;

Expand All @@ -13,7 +14,7 @@ const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;

const SheetPortal = ({ children }: { children: React.ReactNode }) => (
<SheetPrimitive.Portal container={getThemeProviderRoot()}>{children}</SheetPrimitive.Portal>
<SheetPrimitive.Portal container={usePortalContainer()}>{children}</SheetPrimitive.Portal>
);

const SheetOverlay = React.forwardRef<
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/tooltip/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';

import styles from '@/components/tooltip/tooltip.module.css';
import { cn, getThemeProviderRoot } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { usePortalContainer } from '@/theme/portal-container';

const TooltipProvider = TooltipPrimitive.Provider;

Expand All @@ -11,7 +12,7 @@ const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;

const TooltipPortal = ({ children }: { children: React.ReactNode }) => (
<TooltipPrimitive.Portal container={getThemeProviderRoot()}>{children}</TooltipPrimitive.Portal>
<TooltipPrimitive.Portal container={usePortalContainer()}>{children}</TooltipPrimitive.Portal>
);

const TooltipContent = React.forwardRef<
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export * from './components';

/* THEME */
export * from './theme/theme-provider';
export { PORTAL_ROOT_ID, usePortalContainer } from './theme/portal-container';
export * from './theme/portal-container-provider';
export * from './theme/hooks/use-theme';

/* HOOKS */
Expand Down
10 changes: 9 additions & 1 deletion packages/ui/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

import { PORTAL_ROOT_ID } from '@/theme/portal-container';

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

/**
* @deprecated Prefer the `usePortalContainer` hook, which respects a
* `PortalContainerProvider` (or `ThemeProvider`) above it and so works inside a shadow
* root and with more than one theme root on a page. This lookup resolves by a
* document-unique id and can do neither.
*/
export function getThemeProviderRoot() {
return document.getElementById('equality-theme-provider-root-portal');
return document.getElementById(PORTAL_ROOT_ID);
}
28 changes: 28 additions & 0 deletions packages/ui/src/theme/portal-container-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as React from 'react';

import { PortalContainerContext } from './portal-container';

interface PortalContainerProviderProps {
/** Element every portalled surface below this point renders into. */
container: HTMLElement | null;
children: React.ReactNode;
}

/**
* Supplies the container for portalled surfaces — tooltips, popovers, selects, dialogs,
* sheets, drawers, dropdown menus.
*
* `ThemeProvider` renders one of these automatically, so most consumers never need it
* directly. Reach for it when the container has to be an element you own:
*
* - **Shadow DOM.** `document.getElementById` cannot see into a shadow root, so the
* default lookup returns null and every portalled surface escapes to `document.body`
* — outside the shadow boundary, where the design system's styles don't reach.
* - **More than one root on a page.** The default lookup resolves by a document-unique
* id, so a second root's portals land inside the first one's container.
*/
const PortalContainerProvider = ({ container, children }: PortalContainerProviderProps) => (
<PortalContainerContext.Provider value={container}>{children}</PortalContainerContext.Provider>
);

export { PortalContainerProvider };
25 changes: 25 additions & 0 deletions packages/ui/src/theme/portal-container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as React from 'react';

export const PORTAL_ROOT_ID = 'equality-theme-provider-root-portal';

/**
* `undefined` means no provider is mounted, which is distinct from a provider
* deliberately supplying `null` (portal to the document body).
*/
export const PortalContainerContext = React.createContext<HTMLElement | null | undefined>(
undefined
);

/**
* Container for portalled surfaces — tooltips, popovers, selects, dialogs, sheets,
* drawers, dropdown menus.
*
* Resolves to whatever the nearest `PortalContainerProvider` supplies, falling back to
* looking up `#equality-theme-provider-root-portal` in the document so consumers
* predating this context keep working unchanged.
*/
export function usePortalContainer(): HTMLElement | null {
const container = React.useContext(PortalContainerContext);
if (container !== undefined) return container;
return typeof document === 'undefined' ? null : document.getElementById(PORTAL_ROOT_ID);
}
11 changes: 8 additions & 3 deletions packages/ui/src/theme/portal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
const Portal = () => {
return <div id="equality-theme-provider-root-portal" />;
};
import * as React from 'react';

import { PORTAL_ROOT_ID } from './portal-container';

const Portal = React.forwardRef<HTMLDivElement>((_props, ref) => {
return <div id={PORTAL_ROOT_ID} ref={ref} />;
});
Portal.displayName = 'Portal';

export { Portal };
29 changes: 24 additions & 5 deletions packages/ui/src/theme/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
import * as React from 'react';

import { Portal } from './portal';
import { PortalContainerProvider } from './portal-container-provider';
import styles from './theme.module.css';

interface ThemeProviderProps {
customVars?: React.CSSProperties & {
[key: `--${string}`]: string | number | undefined;
};
/**
* Element that portalled surfaces render into, instead of the one this component
* renders for itself.
*
* Needed wherever the default document-wide lookup can't find that element: inside a
* shadow root, or with more than one theme root on a page. Pass `null` to portal to
* the document body.
*/
portalContainer?: HTMLElement | null;
children: React.ReactNode;
}

const ThemeProvider = ({ customVars, children }: ThemeProviderProps) => {
const ThemeProvider = ({ customVars, portalContainer, children }: ThemeProviderProps) => {
// State, not a ref: portalled children read the container while rendering, so they
// need a re-render once the element actually exists.
const [ownPortalContainer, setOwnPortalContainer] = React.useState<HTMLDivElement | null>(null);
const usesOwnPortal = portalContainer === undefined;

return (
<div id="equality-theme-provider-root" className={styles.root} style={customVars}>
{children}
<Portal />
</div>
<PortalContainerProvider container={usesOwnPortal ? ownPortalContainer : portalContainer}>
<div id="equality-theme-provider-root" className={styles.root} style={customVars}>
{children}
{usesOwnPortal && <Portal ref={setOwnPortalContainer} />}
</div>
</PortalContainerProvider>
);
};

Expand Down
Loading