// // Copyright 2022 DXOS.org // import { Children, type CSSProperties, type ForwardedRef, type ForwardRefExoticComponent, type HTMLAttributes, type ReactNode, type RefAttributes, createElement, forwardRef, isValidElement, } from 'react'; import { log } from '@dxos/log'; import { mx } from '@dxos/ui-theme'; import { type ComposableProps, type SlottableProps, type ThemedClassName } from '@dxos/ui-types'; /** * Reconciles className properties from a parent slot. * - `className` is set by the Slot merge mechanism. * - `classNames` is the consumer-facing prop for theming overrides. * Use `composableProps` to reconcile both into a single `className`. */ export const composableProps =

( { className, classNames, role, style, ...props }: ComposableProps, { classNames: defaultClassNames, ...defaults }: ThemedClassName>> | undefined = {}, ) => ({ // Default props. ...(defaults as object), // Spread supplied props. ...props, // Prefer explicit role, then defaults role, then 'none'. role: role ?? defaults.role ?? 'none', // Merge styles. style: { ...defaults.style, ...style } as CSSProperties, // Compose classnames. className: mx(defaultClassNames, className, classNames), }); /** Symbol used to mark components created by `composable()` or `slottable()`. */ const COMPOSABLE = Symbol.for('dxos.composable'); /** * Factory for slottable components. * The implementation receives full `HTMLAttributes` so it can destructure `role`, `style`, etc. * Consumers see only `SlottableProps

` — a narrow type exposing `classNames`, `className`, * `children`, `asChild`, and the custom props `P`. * * @example * ```tsx * const MyPanel = slottable( * ({ children, asChild, border, ...props }, forwardedRef) => { * const Comp = asChild ? Slot : Primitive.div; * return ( * * {children} * * ); * }, * ); * ``` */ export function slottable( render: (props: SlottableProps

& HTMLAttributes, forwardedRef: ForwardedRef) => ReactNode, ): ForwardRefExoticComponent & RefAttributes> { const wrapped = (props: SlottableProps

& HTMLAttributes, forwardedRef: ForwardedRef) => { let warn = false; if (props.asChild) { try { const child = Children.only(props.children); if (isValidElement(child) && typeof child.type !== 'string' && !(child.type as any)[COMPOSABLE]) { warn = true; log.warn('slot child is not composable; create it with composable() or slottable()', { child: (child.type as any).displayName ?? (child.type as any).name, }); } } catch { // Children.only throws if not exactly one child — Slot handles this. } } const result = render(props, forwardedRef); if (warn) { return createElement('div', { role: 'none', className: 'dx-slot-warning' }, result); } return result; }; const component = forwardRef(wrapped as any) as any; (component as any)[COMPOSABLE] = true; return component; } /** * Factory for composable (leaf) components. * The implementation receives full `HTMLAttributes` so it can destructure `role`, `style`, etc. * Consumers see only `ComposableProps

` — a narrow type exposing `classNames`, `className`, * `children`, and the custom props `P`. * * For generic components, use `any` for the type parameter inside `composable` and * cast the result to restore the generic signature for consumers. * * @example * ```tsx * const Leaf = composable(({ children, ...props }, forwardedRef) => { * return ( * * ); * }); * ``` */ export function composable( render: (props: ComposableProps

& HTMLAttributes, forwardedRef: ForwardedRef) => ReactNode, ): ForwardRefExoticComponent & RefAttributes> { const component = forwardRef(render as any) as any; (component as any)[COMPOSABLE] = true; return component; }