diff --git a/.changeset/wise-types-satisfy.md b/.changeset/wise-types-satisfy.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/wise-types-satisfy.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx index 0604785a422..0a432545e3a 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx @@ -33,7 +33,7 @@ export const AutocompleteInput = React.forwardRef), + }), }; return renderElement({ diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx index 27b4e952c6c..a31cf55c764 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx @@ -3,7 +3,7 @@ import { FloatingList, useMergeRefs } from '@floating-ui/react'; import React, { useEffect } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { useAutocompleteContext } from './autocomplete-context'; export type AutocompleteListProps = ComponentProps<'div'>; @@ -24,14 +24,15 @@ export const AutocompleteList = React.forwardRef; + const floatingProps = getFloatingProps(); const wiredId = floatingProps.id; - const defaultProps = { + const ownProps = { 'data-cl-slot': 'autocomplete-list', ref: combinedRef, - ...floatingProps, - }; + } satisfies DefaultProps<'div'>; + + const defaultProps = { ...ownProps, ...floatingProps }; const merged = mergeProps<'div'>(defaultProps, otherProps); // The wired id is owned by the primitive: a consumer-supplied id must not diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx index 38ba5d05237..ddd22e1b799 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx @@ -3,7 +3,7 @@ import { useListItem, useMergeRefs } from '@floating-ui/react'; import React, { useEffect, useId } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { useAutocompleteContext } from './autocomplete-context'; export interface AutocompleteOptionProps extends ComponentProps<'div'> { @@ -43,21 +43,25 @@ export const AutocompleteOption = React.forwardRef; + + const defaultProps = { + ...ownProps, + ...getItemProps({ onClick() { if (!disabled) { handleSelect(value, index, displayLabel); (refs.domReference.current as HTMLElement | null)?.focus(); } }, - }) as React.ComponentPropsWithRef<'div'>), + }), }; const merged = mergeProps<'div'>(defaultProps, otherProps); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx index 4cb4cc03cfa..ccb90a6cdf2 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx @@ -3,7 +3,7 @@ import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { useAutocompleteContext } from './autocomplete-context'; export type AutocompletePositionerProps = ComponentProps<'div'>; @@ -22,16 +22,17 @@ export const AutocompletePositioner = React.forwardRef; + const floatingProps = getFloatingProps(); const wiredId = floatingProps.id; - const defaultProps = { + const ownProps = { 'data-cl-slot': 'autocomplete-positioner', 'data-cl-side': side, ref: combinedRef, style: floatingStyles, - ...floatingProps, - }; + } satisfies DefaultProps<'div'>; + + const defaultProps = { ...ownProps, ...floatingProps }; const merged = mergeProps<'div'>(defaultProps, otherProps); // The wired id is owned by the primitive: a consumer-supplied id must not diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md index f59e19937c8..b18fbc8cf2d 100644 --- a/packages/headless/src/primitives/dialog/README.md +++ b/packages/headless/src/primitives/dialog/README.md @@ -15,13 +15,14 @@ import { Dialog } from '@/primitives/dialog'; Open Dialog - + + Confirm Action Are you sure you want to proceed? Cancel - + ; ``` @@ -51,7 +52,8 @@ const [open, setOpen] = useState(false); | `Dialog.Root` | — | Root context provider | | `Dialog.Trigger` | ` + + Open dialog + + + + Dialog Title + Close + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + + const viewport = document.querySelector('[data-cl-slot="dialog-viewport"]'); + expect(viewport).toHaveStyle({ pointerEvents: 'auto' }); + expect(viewport?.parentElement).toHaveStyle({ pointerEvents: 'none' }); + + await user.click(screen.getByRole('button', { name: 'Background button' })); + expect(onBackgroundClick).toHaveBeenCalledTimes(1); + }); }); describe('focus management', () => { diff --git a/packages/headless/src/primitives/dialog/index.ts b/packages/headless/src/primitives/dialog/index.ts index 664195006a8..b8d7f40953c 100644 --- a/packages/headless/src/primitives/dialog/index.ts +++ b/packages/headless/src/primitives/dialog/index.ts @@ -9,4 +9,5 @@ export type { DialogProps, DialogTitleProps, DialogTriggerProps, + DialogViewportProps, } from './parts'; diff --git a/packages/headless/src/primitives/dialog/parts.ts b/packages/headless/src/primitives/dialog/parts.ts index 9eb19bc8916..7fc3352bcb9 100644 --- a/packages/headless/src/primitives/dialog/parts.ts +++ b/packages/headless/src/primitives/dialog/parts.ts @@ -2,6 +2,7 @@ export { type DialogProps, DialogRoot as Root } from './dialog-root'; export { type DialogTriggerProps, DialogTrigger as Trigger } from './dialog-trigger'; export { type DialogPortalProps, DialogPortal as Portal } from './dialog-portal'; export { type DialogBackdropProps, DialogBackdrop as Backdrop } from './dialog-backdrop'; +export { type DialogViewportProps, DialogViewport as Viewport } from './dialog-viewport'; export { type DialogPopupProps, DialogPopup as Popup } from './dialog-popup'; export { type DialogTitleProps, DialogTitle as Title } from './dialog-title'; export { type DialogDescriptionProps, DialogDescription as Description } from './dialog-description'; diff --git a/packages/headless/src/primitives/menu/menu-item.tsx b/packages/headless/src/primitives/menu/menu-item.tsx index 826c90106f9..26d74829c6a 100644 --- a/packages/headless/src/primitives/menu/menu-item.tsx +++ b/packages/headless/src/primitives/menu/menu-item.tsx @@ -3,7 +3,7 @@ import { useFloatingTree, useListItem, useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { useMenuContext } from './menu-context'; export interface MenuItemProps extends ComponentProps<'button'> { @@ -31,20 +31,24 @@ export const MenuItem = React.forwardRef(funct disabled: !!disabled, }; - const defaultProps = { + const ownProps = { 'data-cl-slot': 'menu-item', - type: 'button' as const, + type: 'button', ref: combinedRef, - role: 'menuitem' as const, + role: 'menuitem', tabIndex: isActive ? 0 : -1, - ...(disabled && { 'aria-disabled': true as const }), - ...(getItemProps({ + ...(disabled && { 'aria-disabled': true }), + } satisfies DefaultProps<'button'>; + + const defaultProps = { + ...ownProps, + ...getItemProps({ onClick() { if (!disabled && closeOnClick) { tree?.events.emit('click'); } }, - }) as React.ComponentPropsWithRef<'button'>), + }), }; return renderElement({ diff --git a/packages/headless/src/primitives/menu/menu-positioner.tsx b/packages/headless/src/primitives/menu/menu-positioner.tsx index 811adc047a5..db1bab027e4 100644 --- a/packages/headless/src/primitives/menu/menu-positioner.tsx +++ b/packages/headless/src/primitives/menu/menu-positioner.tsx @@ -3,7 +3,7 @@ import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { useMenuContext } from './menu-context'; export type MenuPositionerProps = ComponentProps<'div'>; @@ -53,15 +53,16 @@ export const MenuPositioner = React.forwardRef; + }); - const defaultProps = { + const ownProps = { 'data-cl-slot': 'menu-positioner', 'data-cl-side': side, ref: combinedRef, style: floatingStyles, - ...floatingProps, - }; + } satisfies DefaultProps<'div'>; + + const defaultProps = { ...ownProps, ...floatingProps }; if (!mounted) { return null; diff --git a/packages/headless/src/primitives/menu/menu-trigger.tsx b/packages/headless/src/primitives/menu/menu-trigger.tsx index 40a501e7ad8..c639be8caac 100644 --- a/packages/headless/src/primitives/menu/menu-trigger.tsx +++ b/packages/headless/src/primitives/menu/menu-trigger.tsx @@ -3,7 +3,7 @@ import { useListItem, useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { useMenuContext } from './menu-context'; export type MenuTriggerProps = ComponentProps<'button'>; @@ -30,16 +30,17 @@ export const MenuTrigger = React.forwardRef referenceProps = getReferenceProps(); } - const defaultProps = { - type: 'button' as const, + const ownProps = { + type: 'button', 'data-cl-slot': 'menu-trigger', ref: mergedRef, ...(isNested && { - role: 'menuitem' as const, + role: 'menuitem', tabIndex: parentContext?.activeIndex === item.index ? 0 : -1, }), - ...(referenceProps as React.ComponentPropsWithRef<'button'>), - }; + } satisfies DefaultProps<'button'>; + + const defaultProps = { ...ownProps, ...referenceProps }; return renderElement({ defaultTagName: 'button', diff --git a/packages/headless/src/primitives/popover/README.md b/packages/headless/src/primitives/popover/README.md index 7f2060642fd..d72481fa719 100644 --- a/packages/headless/src/primitives/popover/README.md +++ b/packages/headless/src/primitives/popover/README.md @@ -89,11 +89,12 @@ Accepts all `FloatingArrow` props. `ref` and `context` are injected automaticall ## Data Attributes -| Attribute | Applies To | Description | -| --------------------------------- | ----------------- | ---------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (e.g. `"popover-popup"`) | -| `data-cl-open` / `data-cl-closed` | Trigger | Open state | -| `data-cl-side` | Positioner, Arrow | Resolved placement side | +| Attribute | Applies To | Description | +| ------------------------------------------------- | ----------------- | --------------------------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"popover-popup"`) | +| `data-cl-open` / `data-cl-closed` | Trigger, Popup | Open/closed state | +| `data-cl-starting-style` / `data-cl-ending-style` | Popup | Transition state — set during enter/exit animation frames | +| `data-cl-side` | Positioner, Arrow | Resolved placement side | ## Positioning diff --git a/packages/headless/src/primitives/popover/popover-positioner.tsx b/packages/headless/src/primitives/popover/popover-positioner.tsx index 7e6a8e1abae..3a07a70519f 100644 --- a/packages/headless/src/primitives/popover/popover-positioner.tsx +++ b/packages/headless/src/primitives/popover/popover-positioner.tsx @@ -3,7 +3,7 @@ import { FloatingFocusManager, useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { usePopoverContext } from './popover-context'; export type PopoverPositionerProps = ComponentProps<'div'>; @@ -33,15 +33,16 @@ export const PopoverPositioner = React.forwardRef), - }; + } satisfies DefaultProps<'div'>; + + const defaultProps = { ...ownProps, ...getFloatingProps() }; const element = renderElement({ defaultTagName: 'div', diff --git a/packages/headless/src/primitives/popover/popover-trigger.tsx b/packages/headless/src/primitives/popover/popover-trigger.tsx index 8231e348bed..b4b5bc8f17c 100644 --- a/packages/headless/src/primitives/popover/popover-trigger.tsx +++ b/packages/headless/src/primitives/popover/popover-trigger.tsx @@ -3,7 +3,7 @@ import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { usePopoverContext } from './popover-context'; export type PopoverTriggerProps = ComponentProps<'button'>; @@ -21,12 +21,13 @@ export const PopoverTrigger = React.forwardRef), - }; + } satisfies DefaultProps<'button'>; + + const defaultProps = { ...ownProps, ...getReferenceProps() }; return renderElement({ defaultTagName: 'button', diff --git a/packages/headless/src/primitives/select/select-option.tsx b/packages/headless/src/primitives/select/select-option.tsx index 42a76e29897..ce0140d323d 100644 --- a/packages/headless/src/primitives/select/select-option.tsx +++ b/packages/headless/src/primitives/select/select-option.tsx @@ -1,10 +1,9 @@ 'use client'; import { useListItem, useMergeRefs } from '@floating-ui/react'; -import type React from 'react'; import { useEffect } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { useSelectContext } from './select-context'; export interface SelectOptionProps extends ComponentProps<'button'> { @@ -39,21 +38,25 @@ export function SelectOption(props: SelectOptionProps) { disabled: !!disabled, }; - const defaultProps = { + const ownProps = { 'data-cl-slot': 'select-option', - type: 'button' as const, + type: 'button', ref: combinedRef, - role: 'option' as const, + role: 'option', 'aria-selected': isSelected, 'aria-disabled': disabled || undefined, tabIndex: isActive ? 0 : -1, - ...(getItemProps({ + } satisfies DefaultProps<'button'>; + + const defaultProps = { + ...ownProps, + ...getItemProps({ onClick() { if (!disabled) { handleSelect(value, index); } }, - }) as React.ComponentPropsWithRef<'button'>), + }), }; return renderElement({ diff --git a/packages/headless/src/primitives/select/select-positioner.tsx b/packages/headless/src/primitives/select/select-positioner.tsx index 16ba6fee87c..d63b4a610ac 100644 --- a/packages/headless/src/primitives/select/select-positioner.tsx +++ b/packages/headless/src/primitives/select/select-positioner.tsx @@ -3,7 +3,7 @@ import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { useSelectContext } from './select-context'; export type SelectPositionerProps = ComponentProps<'div'>; @@ -52,15 +52,16 @@ export const SelectPositioner = React.forwardRef; + }); - const defaultProps = { + const ownProps = { 'data-cl-slot': 'select-positioner', 'data-cl-side': side, ref: combinedRef, style: floatingStyles, - ...floatingProps, - }; + } satisfies DefaultProps<'div'>; + + const defaultProps = { ...ownProps, ...floatingProps }; const merged = mergeProps<'div'>(defaultProps, otherProps); // The listbox id is owned by floating-ui's listbox role: a consumer-supplied diff --git a/packages/headless/src/primitives/select/select-trigger.tsx b/packages/headless/src/primitives/select/select-trigger.tsx index 750458c18c0..c02bccb4ab9 100644 --- a/packages/headless/src/primitives/select/select-trigger.tsx +++ b/packages/headless/src/primitives/select/select-trigger.tsx @@ -3,7 +3,7 @@ import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { useSelectContext } from './select-context'; export type SelectTriggerProps = ComponentProps<'button'>; @@ -21,12 +21,13 @@ export const SelectTrigger = React.forwardRef), - }; + } satisfies DefaultProps<'button'>; + + const defaultProps = { ...ownProps, ...getReferenceProps() }; return renderElement({ defaultTagName: 'button', diff --git a/packages/headless/src/primitives/tooltip/README.md b/packages/headless/src/primitives/tooltip/README.md index 73567689805..be3f710a2eb 100644 --- a/packages/headless/src/primitives/tooltip/README.md +++ b/packages/headless/src/primitives/tooltip/README.md @@ -93,11 +93,12 @@ Accepts all `FloatingArrow` props. `ref` and `context` are injected automaticall ## Data Attributes -| Attribute | Applies To | Description | -| --------------------------------- | ----------------- | ---------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (e.g. `"tooltip-popup"`) | -| `data-cl-open` / `data-cl-closed` | Trigger | Open state | -| `data-cl-side` | Positioner, Arrow | Resolved placement side | +| Attribute | Applies To | Description | +| ------------------------------------------------- | ----------------- | --------------------------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"tooltip-popup"`) | +| `data-cl-open` / `data-cl-closed` | Trigger, Popup | Open/closed state | +| `data-cl-starting-style` / `data-cl-ending-style` | Popup | Transition state — set during enter/exit animation frames | +| `data-cl-side` | Positioner, Arrow | Resolved placement side | ## Positioning diff --git a/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx b/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx index 2724158a456..221ba845caf 100644 --- a/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx +++ b/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx @@ -3,7 +3,7 @@ import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { useTooltipContext } from './tooltip-context'; export type TooltipPositionerProps = ComponentProps<'div'>; @@ -20,16 +20,17 @@ export const TooltipPositioner = React.forwardRef; + const floatingProps = getFloatingProps(); const wiredId = floatingProps.id; - const defaultProps = { + const ownProps = { 'data-cl-slot': 'tooltip-positioner', 'data-cl-side': side, ref: combinedRef, style: floatingStyles, - ...floatingProps, - }; + } satisfies DefaultProps<'div'>; + + const defaultProps = { ...ownProps, ...floatingProps }; const merged = mergeProps<'div'>(defaultProps, otherProps); // The wired id is owned by floating-ui: it pairs with the trigger's aria-describedby. diff --git a/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx b/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx index 1f54df30f71..86d3bbf40a4 100644 --- a/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx +++ b/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx @@ -3,7 +3,7 @@ import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; import { useTooltipContext } from './tooltip-context'; export type TooltipTriggerProps = ComponentProps<'button'>; @@ -21,12 +21,13 @@ export const TooltipTrigger = React.forwardRef), - }; + } satisfies DefaultProps<'button'>; + + const defaultProps = { ...ownProps, ...getReferenceProps() }; return renderElement({ defaultTagName: 'button', diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index ebfd4caefaa..2f7cb85009c 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -1,2 +1,2 @@ export { cssVars } from './css-vars'; -export { type ComponentProps, mergeProps, type RenderProp, renderElement } from './render-element'; +export { type ComponentProps, type DefaultProps, mergeProps, type RenderProp, renderElement } from './render-element'; diff --git a/packages/headless/src/utils/render-element.tsx b/packages/headless/src/utils/render-element.tsx index 4a1e5f0ba1c..06157b4990e 100644 --- a/packages/headless/src/utils/render-element.tsx +++ b/packages/headless/src/utils/render-element.tsx @@ -17,6 +17,20 @@ export type ComponentProps = Reac render?: RenderProp; }; +/** + * The props a primitive part applies to its own rendered element. Extends the + * native props for `Tag` and additionally permits internal `data-*` attributes + * (e.g. `data-cl-slot`), which `@types/react` intentionally omits from its + * element prop types. + * + * Use with `satisfies` to type-check authored default props — this validates + * every key against the real element props while still allowing our `data-*` + * attributes, instead of laundering the whole object past the checker with an + * `as` assertion. + */ +export type DefaultProps = React.ComponentPropsWithRef & + Record<`data-${string}`, string>; + /** * Maps state keys to functions that return data-attribute objects (or null). */ diff --git a/packages/ui/package.json b/packages/ui/package.json index e27f25afac2..2afa479630c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -98,7 +98,7 @@ "@clerk/shared": "workspace:^", "@emotion/cache": "11.11.0", "@emotion/react": "11.11.1", - "@floating-ui/react": "0.27.12", + "@floating-ui/react": "catalog:repo", "@formkit/auto-animate": "^0.8.2", "@solana/wallet-adapter-base": "catalog:module-manager", "@solana/wallet-adapter-react": "catalog:module-manager", @@ -112,6 +112,7 @@ "qrcode.react": "4.2.0" }, "devDependencies": { + "@clerk/headless": "workspace:^", "@floating-ui/react-dom": "^2.1.8", "@rsdoctor/rspack-plugin": "^0.4.13", "@rspack/cli": "catalog:rspack", diff --git a/packages/ui/src/mosaic/components/button.tsx b/packages/ui/src/mosaic/components/button.tsx index 002d302419b..12ed9bf2176 100644 --- a/packages/ui/src/mosaic/components/button.tsx +++ b/packages/ui/src/mosaic/components/button.tsx @@ -4,6 +4,7 @@ import { cva, type VariantProps } from '../cva'; import { useMosaicTheme } from '../MosaicProvider'; import { hover } from '../utils'; +/** CVA style definition for `Button` variants (`intent`, `size`, `disabled`). */ export const buttonStyles = cva(theme => ({ base: { display: 'inline-flex', @@ -24,7 +25,7 @@ export const buttonStyles = cva(theme => ({ }, }, variants: { - color: { + intent: { primary: { backgroundColor: theme.color.primary, color: theme.color.primaryForeground, @@ -41,13 +42,15 @@ export const buttonStyles = cva(theme => ({ true: { opacity: 0.5, cursor: 'not-allowed', pointerEvents: 'none' }, }, }, - defaultVariants: { color: 'primary', size: 'md', disabled: false }, + defaultVariants: { intent: 'primary', size: 'md', disabled: false }, })); +/** Props for {@link Button}. */ export type ButtonProps = React.ComponentPropsWithRef<'button'> & VariantProps; +/** Styled mosaic Button component with `intent`, `size`, and `disabled` variants. */ export const Button = React.forwardRef(function MosaicButton(props, ref) { - const { color, size, disabled, sx, children, ...rest } = props; + const { intent, size, disabled, sx, children, ...rest } = props; const theme = useMosaicTheme(); return ( diff --git a/packages/ui/src/mosaic/components/dialog.tsx b/packages/ui/src/mosaic/components/dialog.tsx new file mode 100644 index 00000000000..e81be1e51c6 --- /dev/null +++ b/packages/ui/src/mosaic/components/dialog.tsx @@ -0,0 +1,115 @@ +import { cva } from '../cva'; +import { Dialog as Primitive } from '../primitives/dialog'; +import { styled } from '../styled'; + +const backdropStyles = cva({ + base: { + position: 'fixed', + inset: 0, + backgroundColor: 'color-mix(in oklab, #000, transparent 50%)', + transition: 'opacity 150ms', + '&[data-cl-starting-style], &[data-cl-ending-style]': { + opacity: 0, + }, + }, + variants: {}, +}); + +const viewportStyles = cva(theme => ({ + base: { + display: 'grid', + placeItems: 'center', + width: '100%', + minHeight: '100%', + padding: theme.spacing(4), + }, + variants: {}, +})); + +const popupStyles = cva(theme => ({ + base: { + backgroundColor: theme.color.primaryForeground, + color: theme.color.primary, + borderRadius: theme.rounded.lg, + padding: theme.spacing(6), + minWidth: '20rem', + maxWidth: '32rem', + width: '100%', + boxShadow: '0 10px 30px rgba(0,0,0,0.18)', + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + transition: 'transform 150ms ease-out, opacity 150ms ease-out', + '&[data-cl-starting-style], &[data-cl-ending-style]': { + opacity: 0, + transform: 'scale(0.98)', + }, + }, + variants: {}, +})); + +const titleStyles = cva(theme => ({ + base: { + ...theme.text('lg'), + fontWeight: 600, + margin: 0, + }, + variants: {}, +})); + +const descriptionStyles = cva(theme => ({ + base: { + ...theme.text('sm'), + margin: 0, + opacity: 0.8, + }, + variants: {}, +})); + +const closeStyles = cva(theme => ({ + base: { + alignSelf: 'flex-end', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + paddingInline: theme.spacing(3), + paddingBlock: theme.spacing(1), + borderRadius: theme.rounded.md, + backgroundColor: 'transparent', + color: theme.color.primary, + border: `1px solid ${theme.alpha('primary', 20)}`, + ...theme.text('sm'), + cursor: 'pointer', + }, + variants: {}, +})); + +const Backdrop = styled(Primitive.Backdrop, backdropStyles); +const Viewport = styled(Primitive.Viewport, viewportStyles); +const Popup = styled(Primitive.Popup, popupStyles); +const Title = styled(Primitive.Title, titleStyles); +const Description = styled(Primitive.Description, descriptionStyles); +const Close = styled(Primitive.Close, closeStyles); + +/** Styled mosaic Dialog components built on headless Dialog primitives. */ +export const Dialog: { + Root: typeof Primitive.Root; + Trigger: typeof Primitive.Trigger; + Portal: typeof Primitive.Portal; + Backdrop: typeof Backdrop; + Viewport: typeof Viewport; + Popup: typeof Popup; + Title: typeof Title; + Description: typeof Description; + Close: typeof Close; +} = { + Root: Primitive.Root, + Trigger: Primitive.Trigger, + Portal: Primitive.Portal, + Backdrop, + Viewport, + Popup, + Title, + Description, + Close, +}; diff --git a/packages/ui/src/mosaic/cva.ts b/packages/ui/src/mosaic/cva.ts index 0c95c514189..0278cb88632 100644 --- a/packages/ui/src/mosaic/cva.ts +++ b/packages/ui/src/mosaic/cva.ts @@ -23,7 +23,9 @@ export type VariantProps any> = T extends CvaFn ? VariantPropsOf & { sx?: SxProp } : never; // ─── Internal Types ─────────────────────────────────────────────────────────── -type Variants = Record>; + +/** Map of variant axis → variant value → style rule. Exposed so `styled` can type against `cva` results. */ +export type Variants = Record>; /** Converts `'true' | 'false'` string literal keys to `boolean` so callers pass real booleans. */ type UnwrapBooleanVariant = T extends 'true' | 'false' ? boolean : T; @@ -43,7 +45,7 @@ type CvaConfig = { }; /** The curried function returned by `cva`: receives variant props, returns a theme → StyleRule resolver. */ -type CvaFn = { +export type CvaFn = { (props?: VariantPropsOf & { sx?: SxProp }): (theme: MosaicTheme) => StyleRule; /** Variant definitions exposed for tooling. Not part of the public API. */ _variants: V; diff --git a/packages/ui/src/mosaic/primitives/dialog.tsx b/packages/ui/src/mosaic/primitives/dialog.tsx new file mode 100644 index 00000000000..ac2d6df82f2 --- /dev/null +++ b/packages/ui/src/mosaic/primitives/dialog.tsx @@ -0,0 +1,27 @@ +import { Dialog as HeadlessDialog } from '@clerk/headless/dialog'; +import type { DialogPortalProps, DialogProps } from '@clerk/headless/dialog'; +import type { FunctionComponent } from 'react'; + +import { withMosaicTheme } from './withMosaicTheme'; + +/** + * The headless dialog parts bridged into mosaic. Each styleable part is wrapped + * with `withMosaicTheme`, which forwards its ref and adds the `sx` prop — the + * bridged type is inferred, so there is nothing to hand-annotate per part. + * + * The structural parts (`Root`, `Portal`) render no element of their own and + * pass through unchanged; they are cast to their public component types so the + * inferred `Dialog` type stays portable (otherwise it references internal + * `@clerk/headless` declaration paths). + */ +export const Dialog = { + Root: HeadlessDialog.Root as FunctionComponent, + Trigger: withMosaicTheme(HeadlessDialog.Trigger), + Portal: HeadlessDialog.Portal as FunctionComponent, + Backdrop: withMosaicTheme(HeadlessDialog.Backdrop), + Viewport: withMosaicTheme(HeadlessDialog.Viewport), + Popup: withMosaicTheme(HeadlessDialog.Popup), + Title: withMosaicTheme(HeadlessDialog.Title), + Description: withMosaicTheme(HeadlessDialog.Description), + Close: withMosaicTheme(HeadlessDialog.Close), +}; diff --git a/packages/ui/src/mosaic/primitives/withMosaicTheme.tsx b/packages/ui/src/mosaic/primitives/withMosaicTheme.tsx new file mode 100644 index 00000000000..9edd969f37f --- /dev/null +++ b/packages/ui/src/mosaic/primitives/withMosaicTheme.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { useMosaicTheme } from '../MosaicProvider'; +import type { Mosaic } from '../types'; + +/** + * Bridges a headless primitive into mosaic: accepts an `sx` prop (a static + * `StyleRule` or a `(theme) => StyleRule` resolver), resolves it against the + * mosaic theme, and forwards it as an Emotion `css` prop. The headless part + * turns that into a `className` and forwards it to its rendered DOM node. + */ +export const withMosaicTheme = ( + Component: React.FunctionComponent

, +): React.ForwardRefExoticComponent> & React.RefAttributes> => { + const Wrapped = React.forwardRef>((props, ref) => { + const { sx, ...rest } = props; + const theme = useMosaicTheme(); + const css = typeof sx === 'function' ? sx(theme) : sx; + return ( + + ); + }); + Wrapped.displayName = `Mosaic${Component.displayName || Component.name || 'Component'}`; + return Wrapped; +}; diff --git a/packages/ui/src/mosaic/styled.tsx b/packages/ui/src/mosaic/styled.tsx new file mode 100644 index 00000000000..525444cceaf --- /dev/null +++ b/packages/ui/src/mosaic/styled.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import type { CvaFn, SxProp, VariantProps, Variants } from './cva'; + +/** Props of a styled component: the wrapped component's props plus the cva's variant props (including `sx`). */ +type StyledProps, V extends Variants> = React.ComponentProps & + VariantProps>; + +/** + * Wraps a bridged mosaic primitive (e.g. `Dialog.Popup`) with a `cva` style + * definition. Returns a `forwardRef` component whose props are the wrapped + * component's props plus the cva's variant props (including `sx`). + * + * Variant keys are read from `styles._variants` and stripped from the props + * before forwarding, so variant props never leak onto the DOM — there is no + * list to keep in sync. The cva resolver is handed to the primitive as its + * `sx` prop, which `withMosaicTheme` resolves against the theme. + */ +export function styled, V extends Variants>( + Component: C, + styles: CvaFn, +): React.ForwardRefExoticComponent< + React.PropsWithoutRef> & React.RefAttributes> +> { + const variantKeys = Object.keys(styles._variants); + // Bridged primitives forward refs at runtime; widen once so JSX accepts `ref` and the cva resolver as `sx`. + const Forwarded = Component as React.ComponentType< + Record & { sx?: SxProp; ref?: React.Ref } + >; + + const Wrapped = React.forwardRef, StyledProps>(function Styled(props, ref) { + const { sx, ...rest } = props as Record; + const variantArgs: Record = { sx }; + for (const key of variantKeys) { + if (key in rest) { + variantArgs[key] = rest[key]; + delete rest[key]; + } + } + return ( + >[0])} + /> + ); + }); + Wrapped.displayName = `Mosaic(${Component.displayName || Component.name || 'Component'})`; + return Wrapped; +} diff --git a/packages/ui/src/mosaic/types.ts b/packages/ui/src/mosaic/types.ts new file mode 100644 index 00000000000..91e62f4f1a1 --- /dev/null +++ b/packages/ui/src/mosaic/types.ts @@ -0,0 +1,4 @@ +import type { SxProp } from './cva'; + +/** A component prop set augmented with mosaic's `sx` override prop. */ +export type Mosaic = T & { sx?: SxProp }; diff --git a/packages/ui/src/mosaic/variables.ts b/packages/ui/src/mosaic/variables.ts index b766389d1c9..3b46cd921f3 100644 --- a/packages/ui/src/mosaic/variables.ts +++ b/packages/ui/src/mosaic/variables.ts @@ -99,17 +99,17 @@ export function resolveVariables(defaults: MosaicTokens, variables?: MosaicVaria const tokens = variables ? (merge(defaults, variables) as MosaicTokens) : defaults; return { ...tokens, - spacing: ((n: N) => `calc(${tokens.spacing} * ${n})`) as MosaicTheme['spacing'], + spacing: ((n: N) => `calc(${tokens.spacing} * ${n})`) satisfies MosaicTheme['spacing'], alpha: ((color: K, opacity: O) => alpha(tokens.color[color], opacity)) as MosaicTheme['alpha'], mix: (( a: A, b: B, percentage: P, - ) => `color-mix(in oklab, ${tokens.color[a]}, ${tokens.color[b]} ${percentage}%)`) as MosaicTheme['mix'], + ) => `color-mix(in oklab, ${tokens.color[a]}, ${tokens.color[b]} ${percentage}%)`) satisfies MosaicTheme['mix'], text: ((key: K) => ({ fontSize: tokens.text[key].fontSize, lineHeight: tokens.text[key].lineHeight, - })) as MosaicTheme['text'], + })) satisfies MosaicTheme['text'], }; } diff --git a/packages/ui/tsdown.config.mts b/packages/ui/tsdown.config.mts index 1ab785fffb5..fcf7e5e089e 100644 --- a/packages/ui/tsdown.config.mts +++ b/packages/ui/tsdown.config.mts @@ -12,6 +12,7 @@ export default defineConfig(({ watch }) => { target: 'es2022', platform: 'browser', external: ['react', 'react-dom', '@clerk/localizations', '@clerk/shared'], + noExternal: ['@clerk/headless'], format: ['esm'], // ESM only fixedExtension: false, minify: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71859a4ad59..690480568bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1069,7 +1069,7 @@ importers: specifier: 11.11.1 version: 11.11.1(@types/react@18.3.28)(react@18.3.1) '@floating-ui/react': - specifier: 0.27.12 + specifier: catalog:repo version: 0.27.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@formkit/auto-animate': specifier: ^0.8.2 @@ -1111,6 +1111,9 @@ importers: specifier: 18.3.1 version: 18.3.1(react@18.3.1) devDependencies: + '@clerk/headless': + specifier: workspace:^ + version: link:../headless '@floating-ui/react-dom': specifier: ^2.1.8 version: 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)