// ~/src/siteplane/components/dialog/dialog.tsx "use client"; import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; import { XIcon } from "lucide-react"; import * as React from "react"; import { SiteplaneUIPortalScope } from "../../system/provider/siteplane-ui-provider"; import { surfaceClassNames } from "../../system/styles/surface"; import { cn, type WithStringClassName } from "../../system/utils/cn"; import { baseClass, slotClass } from "../../system/utils/slot-class"; import { KitButton } from "../button/button-internal"; import { ScrollArea } from "../scroll-area"; import type { KitDialogPopupProps } from "./dialog-internal"; import "./dialog.css"; export const DialogCreateHandle: typeof DialogPrimitive.createHandle = DialogPrimitive.createHandle; export const Dialog: typeof DialogPrimitive.Root = DialogPrimitive.Root; // Base UI's own Portal type, with className narrowed to a string. export const DialogPortal: React.ForwardRefExoticComponent< WithStringClassName> & React.RefAttributes > = DialogPrimitive.Portal; export function DialogTrigger( props: WithStringClassName, ): React.ReactElement { return ( ); } export function DialogClose( props: WithStringClassName, ): React.ReactElement { return ( ); } export function DialogBackdrop({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export function DialogViewport({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } /** * The width of a Dialog or AlertDialog popup on the modal scale: `xs` 400 px, * `sm` 440 px (the default), `md` 600 px and `lg` 880 px. It applies from the * `sm` breakpoint up; a smaller screen shows the full-width bottom sheet at * every size. */ export type DialogSize = "xs" | "sm" | "md" | "lg"; type DialogPopupProps = WithStringClassName & { showCloseButton?: boolean; /** * The popup width: `xs` 400 px, `sm` 440 px (the default), `md` 600 px or * `lg` 880 px, from the `sm` breakpoint up. A `max-w-*` class in * `className` still wins. */ size?: DialogSize; closeProps?: WithStringClassName; portalProps?: WithStringClassName; }; // The elements a browser can focus with Tab, as Base UI's focus manager finds // them: the candidates that are enabled, rendered, outside an inert or hidden // subtree and not taken out of the tab order. const tabbableCandidates = 'a[href],button,input,select,textarea,summary,details,iframe,object,embed,[tabindex],[contenteditable]:not([contenteditable="false"]),audio[controls],video[controls]'; function firstTabbable(popup: HTMLElement): HTMLElement | null { for (const element of popup.querySelectorAll( tabbableCandidates, )) { if ( element.tabIndex < 0 || element.matches(":disabled") || (element instanceof HTMLInputElement && element.type === "hidden") || element.closest("[inert], [hidden]") || element.getClientRects().length === 0 || getComputedStyle(element).visibility === "hidden" ) continue; return element; } return null; } /** Whether every scrolling box between the element and the popup shows it whole. */ function startsInView(element: HTMLElement, popup: HTMLElement): boolean { const box = element.getBoundingClientRect(); for ( let parent = element.parentElement; parent && popup.contains(parent); parent = parent.parentElement ) { if ( parent.scrollHeight <= parent.clientHeight && parent.scrollWidth <= parent.clientWidth ) continue; // Content that overflows a box which does not clip it stays in view. const { overflowX, overflowY } = getComputedStyle(parent); if (overflowX === "visible" && overflowY === "visible") continue; const area = parent.getBoundingClientRect(); if ( box.top < area.top || box.bottom > area.bottom || box.left < area.left || box.right > area.right ) return false; } return true; } /** * Where focus goes when the dialog opens. Base UI focuses the first tabbable * element and scrolls it into view, so a dialog whose content scrolls opened * at its end when that element sat below the fold, such as a field under long * terms (stress finding S-14). When the first tabbable element starts out of * view, the popup itself takes focus, as Base UI does for a touch open and as * the WAI-ARIA dialog pattern advises for long content: the dialog opens at its * top, a screen reader reads its title, and Tab moves on to that element. A * dialog whose first tabbable element shows keeps Base UI's default. */ function kitInitialFocus( popup: HTMLElement | null, openType: string, ): HTMLElement | true { if (!popup) return true; if (openType === "touch") return popup; const first = firstTabbable(popup); return first && !startsInView(first, popup) ? popup : true; } export function DialogPopup(popupProps: DialogPopupProps): React.ReactElement { // Command and TaskCalendar pass their internal profiles through // KitDialogPopup (dialog-internal.ts), the same function. Only the default // profile takes a size; the others set their own width. const { className, children, showCloseButton = true, profile = "default", size = "sm", closeProps, portalProps, initialFocus, ref: givenRef, ...props } = popupProps as KitDialogPopupProps; const popupRef = React.useRef(null); const ref = React.useCallback( (node: HTMLDivElement | null) => { popupRef.current = node; if (typeof givenRef === "function") givenRef(node); else if (givenRef) givenRef.current = node; }, [givenRef], ); return ( kitInitialFocus(popupRef.current, openType)) } ref={ref} {...props} > {children} {showCloseButton && ( // The corner close of every overlay: the 24 px overlay-dismiss // Button 8 px inside the top-end corner. The header keeps its // text clear of it (design-rework.css). } {...closeProps} > )} ); } export function DialogHeader({ className, render, ...props }: useRender.ComponentProps<"div">): React.ReactElement { const defaultProps = { className: cn( slotClass("dialog-header", props, render), "flex flex-col", className, ), "data-slot": "dialog-header", }; return useRender({ defaultTagName: "div", props: mergeProps<"div">(defaultProps, props), render, }); } export function DialogFooter({ className, render, ...props }: useRender.ComponentProps<"div">): React.ReactElement { const defaultProps = { className: cn( // Actions that do not fit wrap onto another row with the same gap instead // of overflowing a narrow popup. slotClass("dialog-footer", props, render), "flex flex-wrap", // A dialog without content puts 24 px between its text and its actions in // every layout (S12): 8 px here plus the footer's own 16 px top padding. "[[data-slot=dialog-header]+&]:mt-2", className, ), "data-slot": "dialog-footer", }; return useRender({ defaultTagName: "div", props: mergeProps<"div">(defaultProps, props), render, }); } export function DialogTitle({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export function DialogDescription({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export function DialogPanel({ className, scrollFade = true, render, ...props }: useRender.ComponentProps<"div"> & { scrollFade?: boolean; }): React.ReactElement { const defaultProps = { className: cn(slotClass("dialog-panel", props, render), className), "data-slot": "dialog-panel", }; return ( {useRender({ defaultTagName: "div", props: mergeProps<"div">(defaultProps, props), render, })} ); } // DialogOverlay and DialogContent are the shadcn names of DialogBackdrop and // DialogPopup. export { DialogPrimitive, DialogBackdrop as DialogOverlay, DialogPopup as DialogContent, }; // ~/src/siteplane/components/dialog/dialog-internal.ts import type * as React from "react"; import { DialogPopup } from "./dialog"; /* * Kit-internal DialogPopup. Not public API: the dialog index does not export * this module, and its names and profiles may change in any release. Command * renders its dialog with the command profile, and TaskCalendar its day dialog * with the composite profile. */ export type DialogPopupProfile = "composite" | "default" | "command"; export type KitDialogPopupProps = React.ComponentProps & { profile?: DialogPopupProfile; }; /** DialogPopup with the kit's composition profiles; the same component. */ export const KitDialogPopup = DialogPopup as ( props: KitDialogPopupProps, ) => React.ReactElement; // ~/src/siteplane/components/dialog/dialog.css @layer theme, base, components, siteplane, utilities; @layer siteplane { /* The motion of the Dialog popup and its backdrop belongs to * design-rework.css, which Dialog and AlertDialog share. */ /* Global search uses the public Dialog command profile, including its * motion: it appears within 0.8 of the micro step and leaves within half of * it, almost at once. */ :root .sp-command-viewport { display: flex; align-items: center; justify-content: center; padding: var(--sp-rw-space-4); } :root .sp-base-dialog[data-profile="command"] { width: min( calc(100dvw - var(--sp-rw-space-12) - var(--sp-rw-space-12)), 40rem ); height: min(30rem, calc(100dvh - var(--sp-rw-space-8))); max-width: none; max-height: none; overflow: hidden; grid-row: 1; border-radius: var(--sp-rw-radius-dialog); transform: none; transition: opacity calc(var(--motion-duration-micro) * 0.8) var(--motion-ease-linear); } :root .sp-base-dialog[data-profile="command"][data-starting-style], :root .sp-base-dialog[data-profile="command"][data-ending-style] { opacity: 0; transform: none; } :root .sp-base-dialog[data-profile="command"][data-ending-style] { transition-duration: calc(var(--motion-duration-micro) / 2); } @media (min-width: 64rem) { :root .sp-base-dialog[data-profile="command"] { width: min( calc(100dvw - var(--sp-rw-space-12) - var(--sp-rw-space-12)), 48rem ); } } /* Reduced motion shows and hides the search at once (M5). The provider * preset ranks above the profile rule, as the document mode does. */ @media (prefers-reduced-motion: reduce) { :root .sp-base-dialog[data-profile="command"] { transition: none; } } html[data-motion-reduce="true"] .sp-base-dialog[data-profile="command"], :root [data-sp-motion="reduced"] .sp-base-dialog[data-profile="command"] { transition: none; } } // ~/src/siteplane/components/dialog/index.ts export * from "./dialog";