// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { Dialog } from 'radix-ui'; import type { CSSProperties, FC, ForwardedRef, HTMLAttributes, ReactNode, } from 'react'; import React, { forwardRef, memo, useMemo } from 'react'; import { AxoBaseDialog } from './_internal/AxoBaseDialog.dom.js'; import { AxoSymbol } from './AxoSymbol.dom.js'; import { tw } from './tw.dom.js'; import { AxoScrollArea } from './AxoScrollArea.dom.js'; import { getScrollbarGutters } from './_internal/scrollbars.dom.js'; import { AxoButton } from './AxoButton.dom.js'; const Namespace = 'AxoDialog'; const { useContentEscapeBehavior, useContentSize } = AxoBaseDialog; // We want to have 25px of padding on either side of header/body/footer, but // it's import that we remain aligned with the vertical scrollbar gutters that // we need to measure in the browser to know the value of. // // Chrome currently renders vertical scrollbars as 11px with // `scrollbar-width: thin` but that could change someday or based on some OS // settings. So we'll target 24px but we'll tolerate different values. const SCROLLBAR_WIDTH_EXPECTED = 11; /* (keep in sync with chromium) */ const SCROLLBAR_WIDTH_ACTUAL = getScrollbarGutters('thin', 'custom').vertical; const DIALOG_PADDING_TARGET = 20; const DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH = DIALOG_PADDING_TARGET - SCROLLBAR_WIDTH_EXPECTED; const DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH = SCROLLBAR_WIDTH_ACTUAL + DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH; const DIALOG_HEADER_PADDING_BLOCK = 10; const DIALOG_HEADER_ICON_BUTTON_MARGIN = DIALOG_HEADER_PADDING_BLOCK - DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH; export namespace AxoDialog { /** * Component: * --------------------------- */ export type RootProps = Readonly<{ open?: boolean; onOpenChange?: (open: boolean) => void; children: ReactNode; }>; export const Root: FC = memo(props => { return ( {props.children} ); }); Root.displayName = `${Namespace}.Root`; /** * Component: * ------------------------------ */ export type TriggerProps = Readonly<{ children: ReactNode; }>; export const Trigger: FC = memo(props => { return {props.children}; }); Trigger.displayName = `${Namespace}.Trigger`; /** * Component: * ------------------------------ */ export type ContentSize = AxoBaseDialog.ContentSize; export type ContentEscape = AxoBaseDialog.ContentEscape; export type ContentProps = AxoBaseDialog.ContentProps; export const Content: FC = memo(props => { const sizeConfig = AxoBaseDialog.ContentSizes[props.size]; const handleContentEscapeEvent = useContentEscapeBehavior(props.escape); return ( {props.children} ); }); Content.displayName = `${Namespace}.Content`; /** * Component: * ----------------------------- */ export type HeaderProps = Readonly<{ children: ReactNode; }>; export const Header: FC = memo(props => { const style = useMemo(() => { return { paddingBlock: DIALOG_HEADER_PADDING_BLOCK, paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH, }; }, []); return (
{props.children}
); }); Header.displayName = `${Namespace}.Header`; type HeaderIconButtonProps = HTMLAttributes & Readonly<{ label: string; symbol: AxoSymbol.IconName; }>; const HeaderIconButton = forwardRef( ( props: HeaderIconButtonProps, ref: ForwardedRef ): JSX.Element => { const { label, symbol, ...rest } = props; return ( ); } ); HeaderIconButton.displayName = `${Namespace}._HeaderIconButton`; /** * Component: * ---------------------------- */ export type TitleProps = Readonly<{ children: ReactNode; }>; export const Title: FC = memo(props => { return ( {props.children} ); }); Title.displayName = `${Namespace}.Title`; /** * Component: * --------------------------- */ export type BackProps = Readonly<{ 'aria-label': string; }>; export const Back: FC = memo(props => { const style = useMemo((): CSSProperties => { return { marginInlineStart: DIALOG_HEADER_ICON_BUTTON_MARGIN }; }, []); return (
); }); Back.displayName = `${Namespace}.Back`; /** * Component: * ---------------------------- */ export type CloseProps = Readonly<{ 'aria-label': string; }>; export const Close: FC = memo(props => { const style = useMemo((): CSSProperties => { return { marginInlineEnd: DIALOG_HEADER_ICON_BUTTON_MARGIN }; }, []); return (
); }); Close.displayName = `${Namespace}.Close`; /** * Component: * --------------------------- */ export type BodyPadding = 'normal' | 'only-scrollbar-gutter'; export type BodyProps = Readonly<{ padding?: BodyPadding; children: ReactNode; }>; export const Body: FC = memo(props => { const { padding = 'normal' } = props; const contentSize = useContentSize(); const contentSizeConfig = AxoBaseDialog.ContentSizes[contentSize]; const style = useMemo((): CSSProperties => { return { paddingInline: padding === 'normal' ? DIALOG_PADDING_BEFORE_SCROLLBAR_WIDTH : undefined, }; }, [padding]); return (
{props.children}
); }); Body.displayName = `${Namespace}.Body`; /** * Component: * ---------------------------------- */ export type DescriptionProps = Readonly<{ children: ReactNode; }>; export const Description: FC = memo(props => { return {props.children}; }); Description.displayName = `${Namespace}.Description`; /** * Component: * --------------------------- */ export type FooterProps = Readonly<{ children: ReactNode; }>; export const Footer: FC = memo(props => { const style = useMemo((): CSSProperties => { return { paddingInline: DIALOG_PADDING_PLUS_SCROLLBAR_WIDTH, }; }, []); return (
{props.children}
); }); Footer.displayName = `${Namespace}.Footer`; /** * Component: * ------------------------------------ */ export type FooterContentProps = Readonly<{ children: ReactNode; }>; export const FooterContent: FC = memo(props => { return (
{props.children}
); }); FooterContent.displayName = `${Namespace}.FooterContent`; /** * Component: * ------------------------------ */ export type ActionsProps = Readonly<{ children: ReactNode; }>; export const Actions: FC = memo(props => { return (
{props.children}
); }); Actions.displayName = `${Namespace}.Actions`; /** * Component: * ------------------------------ */ export type ActionVariant = 'primary' | 'destructive' | 'secondary'; export type ActionProps = Readonly<{ variant: ActionVariant; symbol?: AxoSymbol.InlineGlyphName; arrow?: boolean; onClick: () => void; children: ReactNode; }>; export const Action: FC = memo(props => { return ( {props.children} ); }); Action.displayName = `${Namespace}.Action`; }