//
// 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
& 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;
}