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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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