From e5e6a675e5929296a7df19d4026e1c85008e6e23 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 31 Jul 2026 08:28:17 -0400 Subject: [PATCH 1/4] refactor(ui): type Mosaic render callbacks from MosaicComponentProps MosaicComponentProps omitted the native color from a component's own props but still typed render with the headless primitive's props, so a render callback handed back a color?: string that collided with a color variant. Every call site worked around it by destructuring the prop away. Type render from MosaicElementProps so the omission is inherited everywhere, and derive Menu's trigger props and Dialog's trigger from MosaicComponentProps rather than re-deriving them from the headless types. Widening back to the primitive's props stays safe, so the callbacks remain assignable to headless. --- .changeset/mosaic-render-props.md | 2 ++ packages/ui/src/mosaic/components/dialog.tsx | 3 +- .../ui/src/mosaic/components/menu/menu.tsx | 29 ++++++++++--------- ...anization-profile-profile-section.view.tsx | 2 +- packages/ui/src/mosaic/props.ts | 22 ++++++++++---- 5 files changed, 37 insertions(+), 21 deletions(-) create mode 100644 .changeset/mosaic-render-props.md diff --git a/.changeset/mosaic-render-props.md b/.changeset/mosaic-render-props.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-render-props.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/components/dialog.tsx b/packages/ui/src/mosaic/components/dialog.tsx index 82867213791..baac978152b 100644 --- a/packages/ui/src/mosaic/components/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog.tsx @@ -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'; @@ -122,7 +123,7 @@ const Popup = React.forwardRef { - trigger: React.ComponentProps['render']; + trigger: MosaicComponentProps<'button'>['render']; children: ReactNode | ((ctx: { close: () => void }) => ReactNode); size?: DialogVariantProps['size']; } diff --git a/packages/ui/src/mosaic/components/menu/menu.tsx b/packages/ui/src/mosaic/components/menu/menu.tsx index 9941cccfc5b..981df196712 100644 --- a/packages/ui/src/mosaic/components/menu/menu.tsx +++ b/packages/ui/src/mosaic/components/menu/menu.tsx @@ -4,20 +4,22 @@ 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 { iconRegistry } from '../../icons/registry'; +import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { Button } from '../button'; import { styles } from './menu.styles'; const EllipsisIcon = iconRegistry.ellipsis; -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; @@ -27,20 +29,21 @@ export const MenuTrigger = React.forwardRef { render, className, style, children, ...rest }, ref, ) { + const trigger: MenuTriggerProps['render'] = + render ?? + (props => ( + -); +const dialogTrigger = (props: RenderProps) => ; export function Default(args: Record) { const { size } = args as { size?: 'md' | 'lg' }; diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index df42713e57d..7906e9554b7 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -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, 'render'> { +export interface ButtonProps extends MosaicElementProps<'button'> { color?: 'primary' | 'neutral' | 'negative'; variant?: 'filled' | 'outline' | 'ghost' | 'link'; size?: 'sm' | 'md' | 'lg'; diff --git a/packages/ui/src/mosaic/components/dialog.tsx b/packages/ui/src/mosaic/components/dialog.tsx index 30e6d315aee..45927daf298 100644 --- a/packages/ui/src/mosaic/components/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from 'react'; import React from 'react'; import { Dialog as Primitive } from '../primitives/dialog'; -import type { MosaicComponentProps, MosaicPartProps } from '../props'; +import type { MosaicComponentProps } from '../props'; import type { RecipeVariantProps } from '../slot-recipe'; import { defineSlotRecipe, useRecipe } from '../slot-recipe'; @@ -82,9 +82,9 @@ declare module '../registry' { } } -export type DialogBackdropProps = MosaicPartProps, 'div'>; -export type DialogViewportProps = MosaicPartProps, 'div'>; -export type DialogPopupProps = MosaicPartProps, 'div'>; +export type DialogBackdropProps = React.ComponentPropsWithoutRef; +export type DialogViewportProps = React.ComponentPropsWithoutRef; +export type DialogPopupProps = React.ComponentPropsWithoutRef; const Backdrop = React.forwardRef(function DialogBackdrop(props, ref) { const { backdrop } = useRecipe(dialogRecipe); diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx index 479298c50f3..2b93ccc66e7 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -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'; @@ -16,7 +16,7 @@ const DEFAULT_SIZE: Size = 'md'; /** Carries `Item.Root`'s size down to the parts it scales (`Item.Media`). */ const ItemContext = React.createContext(DEFAULT_SIZE); -export type ItemProps = Omit, '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. @@ -24,14 +24,6 @@ export type ItemProps = Omit, 'render'> & { * @default 'md' */ size?: Size; - /** - * Render a custom element (e.g. a link or button) in place of the default `div`. - * - * Typed without a `ref`, unlike the inherited `render`: that one pins `ref` to - * the default tag's `HTMLDivElement`, which a callback cannot spread onto the - * `` or `