Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-render-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export {
mergeProps,
type RenderProp,
type RenderPropOrElement,
type RenderProps,
useRender,
} from './use-render';
34 changes: 28 additions & 6 deletions packages/headless/src/utils/use-render.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
import type React from 'react';
import { describe, expectTypeOf, test } from 'vitest';

import type { ComponentProps } from './use-render';
import type { ComponentProps, RenderProps } from './use-render';

type RenderFn<Tag extends keyof React.JSX.IntrinsicElements> = Extract<
NonNullable<ComponentProps<Tag>['render']>,
(...args: never[]) => unknown
>;
type RenderArg<Tag extends keyof React.JSX.IntrinsicElements> =
RenderFn<Tag> extends (props: infer P) => React.ReactElement ? P : never;
type HasColor<P> = 'color' extends keyof P ? true : false;

describe('use-render', () => {
test('render prop arg is narrowed to the element tag props, not the generic HTMLAttributes<HTMLElement>', () => {
type Props = ComponentProps<'button'>;
type RenderFn = Extract<NonNullable<Props['render']>, (...args: never[]) => unknown>;
type RenderArg = RenderFn extends (props: infer P) => React.ReactElement ? P : never;
expectTypeOf<RenderArg>().toEqualTypeOf<React.ComponentPropsWithRef<'button'>>();
test('a part keeps its own tag props', () => {
expectTypeOf<ComponentProps<'button'>>().toExtend<{ type?: 'button' | 'submit' | 'reset' }>();
});

test('the legacy `color` attribute is dropped, so it cannot widen a `color` variant', () => {
expectTypeOf<HasColor<ComponentProps<'button'>>>().toEqualTypeOf<false>();
expectTypeOf<HasColor<RenderArg<'button'>>>().toEqualTypeOf<false>();
});

test('the render arg is the tag-agnostic RenderProps, not the default tag props', () => {
expectTypeOf<RenderArg<'button'>>().toEqualTypeOf<RenderProps>();
expectTypeOf<RenderArg<'div'>>().toEqualTypeOf<RenderProps>();
});

test('render props spread onto an element other than the default tag', () => {
// The point of `render`: a `div` part rendering an `<a>`. A tag-pinned `ref`
// would make this fail, which is what every call site used to work around.
expectTypeOf<RenderProps>().toExtend<React.ComponentPropsWithRef<'a'>>();
expectTypeOf<RenderProps>().toExtend<React.ComponentPropsWithRef<'button'>>();
});

test('render also accepts an element to clone', () => {
Expand Down
49 changes: 34 additions & 15 deletions packages/headless/src/utils/use-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,48 @@ import * as React from 'react';
// Types
// ---------------------------------------------------------------------------

/**
* The props a `render` callback receives, deliberately *not* the default tag's props.
*
* `render` exists to swap the rendered element, so the element's type is unknown at
* the point this is declared. Two things follow, and both were previously worked
* around at each call site instead of here:
*
* - `ref` is not pinned to the default tag. A `div` part rendering an `<a>` could
* not spread its props, because `Ref<HTMLDivElement>` is not a `Ref<HTMLAnchorElement>`.
* - `color` is dropped. It is a non-standard HTML attribute typed `string`, so it
* collides with the `color` variant a styled component spreads these props into.
*/
export type RenderProps = Omit<React.HTMLAttributes<HTMLElement>, 'color'> & {
// SAFETY: the rendered element is chosen by the callback, after this type is fixed, so
// no concrete element type is correct here. `Ref<Element>` does not work: `RefObject<Element>`
// is not a `RefObject<HTMLAnchorElement>`. `any` is what makes the ref spreadable onto
// whatever the callback returns, which is the whole point of `render`. Base UI's
// `HTMLProps` resolves this the same way.
ref?: React.Ref<any>;
};

/**
* A render prop: a function that receives computed HTML props and returns a JSX element.
*/
export type RenderProp<Props = React.HTMLAttributes<HTMLElement>> = (props: Props) => React.ReactElement;
export type RenderProp<Props = RenderProps> = (props: Props) => React.ReactElement;

/**
* A `render` prop: a render function receiving the part's computed props, or a
* React element to clone with them (`render={<Link/>}`). The element form lets a
* part render a component whose own props diverge from the tag's, which a render
* function cannot express — it is typed to receive the tag's props verbatim.
* React element to clone with them (`render={<Link/>}`).
*/
export type RenderPropOrElement<Tag extends keyof React.JSX.IntrinsicElements> =
| RenderProp<React.ComponentPropsWithRef<Tag>>
| React.ReactElement;
export type RenderPropOrElement = RenderProp | React.ReactElement;

/**
* Props accepted by any primitive part. Extends the native props for `Tag`
* and adds the optional `render` escape hatch, narrowed to that tag's props.
* Props accepted by any primitive part: the native props for `Tag` plus the
* optional `render` escape hatch. `color` is dropped for the reason given on
* `RenderProps` — a part and its render callback expose the same contract.
*/
export type ComponentProps<Tag extends keyof React.JSX.IntrinsicElements> = React.ComponentPropsWithRef<Tag> & {
render?: RenderPropOrElement<Tag>;
export type ComponentProps<Tag extends keyof React.JSX.IntrinsicElements> = Omit<
React.ComponentPropsWithRef<Tag>,
'color'
> & {
render?: RenderPropOrElement;
};

/**
Expand Down Expand Up @@ -125,7 +146,7 @@ interface UseRenderParamsBase<
/** Fallback HTML tag when `render` is not provided. */
defaultTagName: Tag;
/** Render prop or element from the consumer. */
render?: RenderPropOrElement<Tag>;
render?: RenderPropOrElement;
/** Ref(s) to merge onto the rendered element. Merged with the element's own ref. */
ref?: React.Ref<unknown> | Array<React.Ref<unknown> | undefined>;
/** State object. Keys are mapped to data attributes via `stateAttributesMapping`. */
Expand Down Expand Up @@ -197,9 +218,7 @@ export function useRender<
const computedProps = { ...props, ...dataAttrs };

if (typeof render === 'function') {
// SAFETY: computedProps is the tag's props widened with data-* attrs; the render
// function is declared to receive this tag's props.
return render({ ...computedProps, ref: mergedRef } as React.ComponentPropsWithRef<Tag>);
return render({ ...computedProps, ref: mergedRef });
}

if (React.isValidElement(render)) {
Expand Down
5 changes: 2 additions & 3 deletions packages/swingset/src/stories/dialog.component.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** @jsxImportSource @emotion/react */
import type { RenderProps } from '@clerk/headless/utils';
import { Button } from '@clerk/ui/mosaic/components/button';
import { Dialog, dialogRecipe } from '@clerk/ui/mosaic/components/dialog';

Expand All @@ -15,9 +16,7 @@ export const meta: StoryMeta = {
styles: dialogRecipe,
};

const dialogTrigger = ({ color: _nativeColor, ...props }: React.HTMLAttributes<HTMLElement>) => (
<Button {...props}>Open dialog</Button>
);
const dialogTrigger = (props: RenderProps) => <Button {...props}>Open dialog</Button>;

export function Default(args: Record<string, unknown>) {
const { size } = args as { size?: 'md' | 'lg' };
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/mosaic/components/button/button.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import * as stylex from '@stylexjs/stylex';
import React from 'react';

import type { MosaicComponentProps } from '../../props';
import type { MosaicElementProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { truncationStyles } from '../typography.styles';
import { iconSizes, sizes, styles, variants } from './button.styles';

export interface ButtonProps extends Omit<MosaicComponentProps<'button'>, 'render'> {
export interface ButtonProps extends MosaicElementProps<'button'> {
color?: 'primary' | 'neutral' | 'negative';
variant?: 'filled' | 'outline' | 'ghost' | 'link';
size?: 'sm' | 'md' | 'lg';
Expand Down
75 changes: 37 additions & 38 deletions packages/ui/src/mosaic/components/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
import React from 'react';

import { Dialog as Primitive } from '../primitives/dialog';
import type { MosaicComponentProps } from '../props';
import type { RecipeVariantProps } from '../slot-recipe';
import { defineSlotRecipe, useRecipe } from '../slot-recipe';

Expand Down Expand Up @@ -81,48 +82,46 @@ declare module '../registry' {
}
}

const Backdrop = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<typeof Primitive.Backdrop>>(
function DialogBackdrop(props, ref) {
const { backdrop } = useRecipe(dialogRecipe);
return (
<Primitive.Backdrop
ref={ref}
{...props}
{...backdrop}
/>
);
},
);
export type DialogBackdropProps = React.ComponentPropsWithoutRef<typeof Primitive.Backdrop>;
export type DialogViewportProps = React.ComponentPropsWithoutRef<typeof Primitive.Viewport>;
export type DialogPopupProps = React.ComponentPropsWithoutRef<typeof Primitive.Popup>;

const Viewport = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<typeof Primitive.Viewport>>(
function DialogViewport(props, ref) {
const { viewport } = useRecipe(dialogRecipe);
return (
<Primitive.Viewport
ref={ref}
{...props}
{...viewport}
/>
);
},
);
const Backdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(function DialogBackdrop(props, ref) {
const { backdrop } = useRecipe(dialogRecipe);
return (
<Primitive.Backdrop
ref={ref}
{...props}
{...backdrop}
/>
);
});

const Popup = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<typeof Primitive.Popup>>(
function DialogPopup(props, ref) {
const variantProps = React.useContext(DialogVariantContext);
const { popup } = useRecipe(dialogRecipe, { variants: variantProps });
return (
<Primitive.Popup
ref={ref}
{...props}
{...popup}
/>
);
},
);
const Viewport = React.forwardRef<HTMLDivElement, DialogViewportProps>(function DialogViewport(props, ref) {
const { viewport } = useRecipe(dialogRecipe);
return (
<Primitive.Viewport
ref={ref}
{...props}
{...viewport}
/>
);
});

const Popup = React.forwardRef<HTMLDivElement, DialogPopupProps>(function DialogPopup(props, ref) {
const variantProps = React.useContext(DialogVariantContext);
const { popup } = useRecipe(dialogRecipe, { variants: variantProps });
return (
<Primitive.Popup
ref={ref}
{...props}
{...popup}
/>
);
});

interface DialogProps extends Pick<HeadlessDialogProps, 'open' | 'defaultOpen' | 'onOpenChange' | 'modal'> {
trigger: React.ComponentProps<typeof Primitive.Trigger>['render'];
trigger: MosaicComponentProps<'button'>['render'];
children: ReactNode | ((ctx: { close: () => void }) => ReactNode);
size?: DialogVariantProps['size'];
}
Expand Down
6 changes: 2 additions & 4 deletions packages/ui/src/mosaic/components/item/item.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type RenderProp, useRender } from '@clerk/headless/utils';
import { useRender } from '@clerk/headless/utils';
import * as stylex from '@stylexjs/stylex';
import React from 'react';

Expand All @@ -16,16 +16,14 @@ const DEFAULT_SIZE: Size = 'md';
/** Carries `Item.Root`'s size down to the parts it scales (`Item.Media`). */
const ItemContext = React.createContext<Size>(DEFAULT_SIZE);

export type ItemProps = Omit<MosaicComponentProps<'div'>, 'render'> & {
export type ItemProps = MosaicComponentProps<'div'> & {
/**
* Row height and gap. Also sizes a nested `Item.Media`, which reads this from
* context rather than taking its own prop, so a row scales as one unit.
*
* @default 'md'
*/
size?: Size;
/** Render a custom element (e.g. a link or button) in place of the default `div`. */
render?: RenderProp<React.HTMLAttributes<HTMLElement>>;
};

/**
Expand Down
29 changes: 16 additions & 13 deletions packages/ui/src/mosaic/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ import type {
MenuPortalProps,
MenuProps,
MenuSeparatorProps,
MenuTriggerProps,
} from '@clerk/headless/menu';
import { Menu as Primitive } from '@clerk/headless/menu';
import * as stylex from '@stylexjs/stylex';
import React from 'react';

import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { Button } from '../button';
import { Icon } from '../icon';
import { styles } from './menu.styles';

export type { MenuProps, MenuSeparatorProps, MenuTriggerProps };
export type { MenuProps, MenuSeparatorProps };

export type MenuTriggerProps = MosaicComponentProps<'button'>;

/**
* Opens the menu. Renders a ghost `Button` holding an ellipsis glyph by default;
Expand All @@ -25,20 +27,21 @@ export const MenuTrigger = React.forwardRef<HTMLButtonElement, MenuTriggerProps>
{ render, className, style, children, ...rest },
ref,
) {
const trigger: MenuTriggerProps['render'] =
render ??
(props => (
<Button
variant='ghost'
size='sm'
shape={children ? 'default' : 'square'}
{...props}
/>
));

return (
<Primitive.Trigger
ref={ref}
render={
render ??
(({ color: _nativeColor, ...props }) => (
<Button
variant='ghost'
size='sm'
shape={children ? 'default' : 'square'}
{...props}
/>
))
}
render={trigger}
{...mergeStyleProps(themeProps('menu-trigger'), className, style)}
{...rest}
>
Expand Down
Loading
Loading