Move portal to shared ui lib

This commit is contained in:
Gregory Schier
2026-03-12 14:28:15 -07:00
parent 47f0daabff
commit cc504e0a1c
10 changed files with 52 additions and 14 deletions

View File

@@ -0,0 +1,90 @@
import classNames from 'classnames';
import { FocusTrap } from 'focus-trap-react';
import * as m from 'motion/react-m';
import type { ReactNode } from 'react';
import { useRef } from 'react';
import { Portal } from './Portal';
interface Props {
children: ReactNode;
portalName: string;
open: boolean;
onClose?: () => void;
zIndex?: keyof typeof zIndexes;
variant?: 'default' | 'transparent';
noBackdrop?: boolean;
}
const zIndexes: Record<number, string> = {
10: 'z-10',
20: 'z-20',
30: 'z-30',
40: 'z-40',
50: 'z-50',
};
export function Overlay({
variant = 'default',
zIndex = 30,
open,
onClose,
portalName,
noBackdrop,
children,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
if (noBackdrop) {
return (
<Portal name={portalName}>
{open && (
<FocusTrap focusTrapOptions={{ clickOutsideDeactivates: true }}>
{/* NOTE: <div> wrapper is required for some reason, or FocusTrap complains */}
<div>{children}</div>
</FocusTrap>
)}
</Portal>
);
}
return (
<Portal name={portalName}>
{open && (
<FocusTrap
focusTrapOptions={{
// Allow outside click so we can click things like toasts
allowOutsideClick: true,
delayInitialFocus: true,
checkCanFocusTrap: async () => {
// Not sure why delayInitialFocus: true doesn't help, but having this no-op promise
// seems to be required to make things work.
},
}}
>
<m.div
ref={containerRef}
className={classNames('fixed inset-0', zIndexes[zIndex])}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div
aria-hidden
onClick={onClose}
className={classNames(
'absolute inset-0',
variant === 'default' && 'bg-backdrop backdrop-blur-sm',
)}
/>
{/* Show the draggable region at the top */}
{/* TODO: Figure out tauri drag region and also make clickable still */}
{variant === 'default' && (
<div data-tauri-drag-region className="absolute top-0 left-0 h-md right-0" />
)}
{children}
</m.div>
</FocusTrap>
)}
</Portal>
);
}

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { usePortal } from '../hooks/usePortal';
interface Props {
children: ReactNode;
name: string;
}
export function Portal({ children, name }: Props) {
const portal = usePortal(name);
return createPortal(children, portal);
}

View File

@@ -13,6 +13,8 @@ interface Props {
onWidthChange: (width: number) => void;
hidden?: boolean;
onHiddenChange?: (hidden: boolean) => void;
floating?: boolean;
floatingWidth?: number;
defaultWidth?: number;
minWidth?: number;
className?: string;
@@ -25,6 +27,8 @@ export function SidebarLayout({
onWidthChange,
hidden = false,
onHiddenChange,
floating = false,
floatingWidth = 320,
defaultWidth = 250,
minWidth = 50,
className,
@@ -75,6 +79,28 @@ export function SidebarLayout({
onWidthChange(defaultWidth);
}, [onWidthChange, defaultWidth]);
if (floating) {
return (
<div className={classNames(className, 'relative w-full h-full overflow-hidden')}>
{children}
{!hidden && (
<>
<div
className="absolute inset-0 bg-black/50 z-20 transition-opacity"
onClick={() => onHiddenChange?.(true)}
/>
<div
style={{ width: floatingWidth }}
className="absolute top-0 left-0 bottom-0 z-20 animate-slide-in-left"
>
{sidebar}
</div>
</>
)}
</div>
);
}
return (
<div
style={styles}

View File

@@ -0,0 +1,26 @@
import { useRef } from 'react';
const PORTAL_CONTAINER_ID = 'react-portal';
export function usePortal(name: string) {
const ref = useRef(getOrCreatePortal(name));
return ref.current;
}
function getOrCreatePortal(name: string) {
let portalContainer = document.getElementById(PORTAL_CONTAINER_ID);
if (!portalContainer) {
portalContainer = document.createElement('div');
portalContainer.id = PORTAL_CONTAINER_ID;
document.body.appendChild(portalContainer);
}
let existing = portalContainer.querySelector(`:scope > [data-portal-name="${name}"]`);
if (!existing) {
const el = document.createElement('div');
el.setAttribute('data-portal-name', name);
portalContainer.appendChild(el);
existing = el;
}
return existing;
}

View File

@@ -28,3 +28,6 @@ export type { SplitLayoutLayout, SlotProps } from "./components/SplitLayout";
export { Table, TableBody, TableHead, TableRow, TableCell, TruncatedWideTableCell, TableHeaderCell } from "./components/Table";
export { clamp } from "./lib/clamp";
export { useContainerSize } from "./hooks/useContainerSize";
export { Overlay } from "./components/Overlay";
export { Portal } from "./components/Portal";
export { usePortal } from "./hooks/usePortal";