diff --git a/.changeset/headless-primitives-use-render.md b/.changeset/headless-primitives-use-render.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/headless-primitives-use-render.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/accordion/accordion-header.tsx b/packages/headless/src/primitives/accordion/accordion-header.tsx index 8987def3e40..903235f2a07 100644 --- a/packages/headless/src/primitives/accordion/accordion-header.tsx +++ b/packages/headless/src/primitives/accordion/accordion-header.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; export type AccordionHeaderProps = ComponentProps<'h3'>; @@ -11,7 +11,7 @@ export function AccordionHeader(props: AccordionHeaderProps) { 'data-cl-slot': 'accordion-header', }; - return renderElement({ + return useRender({ defaultTagName: 'h3', render, props: mergeProps<'h3'>(defaultProps, otherProps), diff --git a/packages/headless/src/primitives/accordion/accordion-item.tsx b/packages/headless/src/primitives/accordion/accordion-item.tsx index d9ed9d6d488..f772793544c 100644 --- a/packages/headless/src/primitives/accordion/accordion-item.tsx +++ b/packages/headless/src/primitives/accordion/accordion-item.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context'; export interface AccordionItemProps extends ComponentProps<'div'> { @@ -34,7 +34,7 @@ export function AccordionItem(props: AccordionItemProps) { return ( - {renderElement({ + {useRender({ defaultTagName: 'div', render, state, diff --git a/packages/headless/src/primitives/accordion/accordion-panel.tsx b/packages/headless/src/primitives/accordion/accordion-panel.tsx index 8e2fe7d668f..fab5eca48ee 100644 --- a/packages/headless/src/primitives/accordion/accordion-panel.tsx +++ b/packages/headless/src/primitives/accordion/accordion-panel.tsx @@ -1,11 +1,10 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react'; import { useAnimationsFinished } from '../../hooks/use-animations-finished'; import { useTransition } from '../../hooks/use-transition'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { resetLayoutStyles } from '../../utils/reset-layout-styles'; import { useAccordionItemContext } from './accordion-context'; @@ -17,9 +16,6 @@ export const AccordionPanel = React.forwardRef(null); - // Merge the consumer ref with the internal panelRef so passing a ref does not - // clobber the ref the panel relies on for height measurement. - const combinedRef = useMergeRefs([panelRef, ref]); const [height, setHeight] = useState(undefined); // Track whether open has ever transitioned from true→false. @@ -108,7 +104,6 @@ export const AccordionPanel = React.forwardRef | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), diff --git a/packages/headless/src/primitives/accordion/accordion-root.tsx b/packages/headless/src/primitives/accordion/accordion-root.tsx index d00c2aa84f7..c7f6e93d7ae 100644 --- a/packages/headless/src/primitives/accordion/accordion-root.tsx +++ b/packages/headless/src/primitives/accordion/accordion-root.tsx @@ -4,7 +4,7 @@ import { Composite } from '@floating-ui/react'; import React, { type ReactNode, useCallback, useId, useMemo } from 'react'; import { useControllableState } from '../../hooks/use-controllable-state'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { AccordionContext, type AccordionContextValue } from './accordion-context'; export interface AccordionProps extends ComponentProps<'div'> { @@ -79,10 +79,22 @@ export function AccordionRoot(props: AccordionProps) { mergeProps<'div'>(otherProps, restCompositeProps as Record), ); - return renderElement({ + // Composite may inject a ref via compositeProps; hand it to useRender's ref + // param (which owns ref-merging) instead of leaving it in props, where + // useRender's merged ref would overwrite it. + const { ref: compositeRef, ...mergedProps } = merged; + + // floating-ui's Composite invokes this render callback synchronously and + // unconditionally during its own render (see renderJsx), so useRender runs in a + // stable hook position on the Composite fiber. The rule can't see that. + // eslint-disable-next-line react-hooks/rules-of-hooks + return useRender({ defaultTagName: 'div', render, - props: merged, + // SAFETY: mergeProps returns Record; a ref Composite injected + // is a valid React ref at runtime. + ref: compositeRef as React.Ref, + props: mergedProps, }); }} > diff --git a/packages/headless/src/primitives/accordion/accordion-trigger.tsx b/packages/headless/src/primitives/accordion/accordion-trigger.tsx index 7d3bc999ba2..4e6ca83f46e 100644 --- a/packages/headless/src/primitives/accordion/accordion-trigger.tsx +++ b/packages/headless/src/primitives/accordion/accordion-trigger.tsx @@ -3,7 +3,7 @@ import { CompositeItem } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useAccordionContext, useAccordionItemContext } from './accordion-context'; export type AccordionTriggerProps = ComponentProps<'button'>; @@ -42,16 +42,28 @@ export function AccordionTrigger(props: AccordionTriggerProps) { // not override it, or the trigger/panel aria pairing would silently break. merged.id = triggerId; - return renderElement({ + // CompositeItem injects its roving-tabindex ref via compositeProps; hand it to + // useRender's ref param (which owns ref-merging) instead of leaving it in props, + // where useRender's merged ref would overwrite it and break focus navigation. + const { ref: compositeRef, ...mergedProps } = merged; + + // floating-ui's CompositeItem invokes this render callback synchronously and + // unconditionally during its own render (see renderJsx), so useRender runs in a + // stable hook position on the CompositeItem fiber. The rule can't see that. + // eslint-disable-next-line react-hooks/rules-of-hooks + return useRender({ defaultTagName: 'button', render, + // SAFETY: mergeProps returns Record; the ref CompositeItem + // injected is a valid React ref at runtime. + ref: compositeRef as React.Ref, state, stateAttributesMapping: { open: (v: boolean): Record | null => v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }, disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), }, - props: merged, + props: mergedProps, }); }} > diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx index 0a432545e3a..68ee1645fa1 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useAutocompleteContext } from './autocomplete-context'; export type AutocompleteInputProps = ComponentProps<'input'>; @@ -23,18 +22,11 @@ export const AutocompleteInput = React.forwardRef) { @@ -53,9 +45,14 @@ export const AutocompleteInput = React.forwardRef | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx index a31cf55c764..6820b00d6d1 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx @@ -1,9 +1,9 @@ 'use client'; -import { FloatingList, useMergeRefs } from '@floating-ui/react'; +import { FloatingList } from '@floating-ui/react'; import React, { useEffect } from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useAutocompleteContext } from './autocomplete-context'; export type AutocompleteListProps = ComponentProps<'div'>; @@ -18,18 +18,11 @@ export const AutocompleteList = React.forwardRef setInlineMode(false); }, [setInlineMode]); - // floating-ui types `setFloating` as a method signature, but at runtime it's - // a stable callback that doesn't use `this`, so the unbound-method check is a - // false positive here. - // eslint-disable-next-line @typescript-eslint/unbound-method - const combinedRef = useMergeRefs([refs.setFloating, ref]); - const floatingProps = getFloatingProps(); const wiredId = floatingProps.id; const ownProps = { 'data-cl-slot': 'autocomplete-list', - ref: combinedRef, } satisfies DefaultProps<'div'>; const defaultProps = { ...ownProps, ...floatingProps }; @@ -46,9 +39,14 @@ export const AutocompleteList = React.forwardRef - {renderElement({ + {useRender({ defaultTagName: 'div', render, + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + ref: [refs.setFloating, ref], props: merged, })} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx index ddd22e1b799..4d13f5525b7 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useListItem, useMergeRefs } from '@floating-ui/react'; +import { useListItem } from '@floating-ui/react'; import React, { useEffect, useId } from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useAutocompleteContext } from './autocomplete-context'; export interface AutocompleteOptionProps extends ComponentProps<'div'> { @@ -21,7 +21,6 @@ export const AutocompleteOption = React.forwardRef (v ? { 'data-cl-selected': '' } : null), diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx index 74f443ec5be..8acfaf2dff2 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useAutocompleteContext } from './autocomplete-context'; export type AutocompletePopupProps = ComponentProps<'div'>; @@ -13,17 +12,15 @@ export const AutocompletePopup = React.forwardRef(defaultProps, otherProps), }); }, diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx index ccb90a6cdf2..140cf07d921 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx @@ -1,9 +1,9 @@ 'use client'; -import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react'; +import { FloatingFocusManager, FloatingList } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useAutocompleteContext } from './autocomplete-context'; export type AutocompletePositionerProps = ComponentProps<'div'>; @@ -16,19 +16,12 @@ export const AutocompletePositioner = React.forwardRef; @@ -41,6 +34,22 @@ export const AutocompletePositioner = React.forwardRef - {renderElement({ - defaultTagName: 'div', - render, - enabled: mounted, - props: merged, - })} + {element} ); diff --git a/packages/headless/src/primitives/collapsible/collapsible-panel.tsx b/packages/headless/src/primitives/collapsible/collapsible-panel.tsx index 2b0d8f3b1c0..65257b9fb9d 100644 --- a/packages/headless/src/primitives/collapsible/collapsible-panel.tsx +++ b/packages/headless/src/primitives/collapsible/collapsible-panel.tsx @@ -1,11 +1,10 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react'; import { useAnimationsFinished } from '../../hooks/use-animations-finished'; import { useTransition } from '../../hooks/use-transition'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { resetLayoutStyles } from '../../utils/reset-layout-styles'; import { useCollapsibleContext } from './collapsible-context'; @@ -17,9 +16,6 @@ export const CollapsiblePanel = React.forwardRef(null); - // Merge the consumer ref with the internal panelRef so passing a ref does not - // clobber the ref the panel relies on for height measurement. - const combinedRef = useMergeRefs([panelRef, ref]); const [height, setHeight] = useState(undefined); const [width, setWidth] = useState(undefined); @@ -109,7 +105,6 @@ export const CollapsiblePanel = React.forwardRef | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), diff --git a/packages/headless/src/primitives/collapsible/collapsible-root.tsx b/packages/headless/src/primitives/collapsible/collapsible-root.tsx index 4c94e060e75..eccdbd10aa8 100644 --- a/packages/headless/src/primitives/collapsible/collapsible-root.tsx +++ b/packages/headless/src/primitives/collapsible/collapsible-root.tsx @@ -3,7 +3,7 @@ import { type ReactNode, useId, useMemo } from 'react'; import { useControllableState } from '../../hooks/use-controllable-state'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { CollapsibleContext, type CollapsibleContextValue } from './collapsible-context'; export interface CollapsibleProps extends ComponentProps<'div'> { @@ -41,7 +41,7 @@ export function CollapsibleRoot(props: CollapsibleProps) { return ( - {renderElement({ + {useRender({ defaultTagName: 'div', render, state, diff --git a/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx b/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx index 63d9555e973..c034db78fb7 100644 --- a/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx +++ b/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useCollapsibleContext } from './collapsible-context'; export type CollapsibleTriggerProps = ComponentProps<'button'>; @@ -30,7 +30,7 @@ export function CollapsibleTrigger(props: CollapsibleTriggerProps) { // override it, or the trigger/panel aria pairing would silently break. merged.id = triggerId; - return renderElement({ + return useRender({ defaultTagName: 'button', render, state, diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md index c68f4440467..49c02236eaf 100644 --- a/packages/headless/src/primitives/dialog/README.md +++ b/packages/headless/src/primitives/dialog/README.md @@ -112,7 +112,7 @@ Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by t ## Authoring rule for new primitives -Each styleable surface = one part. Layout infrastructure (overlay, scroll lock, focus manager, portal) wraps a `renderElement` call rather than fusing with it. The dialog split — `Backdrop` (semi-transparent surface) vs. `Viewport` (fixed centering + scroll lock) — exists because mosaic needs to style each layer independently. Apply the same decomposition to future primitives that combine positioning with a styled surface. +Each styleable surface = one part. Layout infrastructure (overlay, scroll lock, focus manager, portal) wraps a `useRender` call rather than fusing with it. The dialog split — `Backdrop` (semi-transparent surface) vs. `Viewport` (fixed centering + scroll lock) — exists because mosaic needs to style each layer independently. Apply the same decomposition to future primitives that combine positioning with a styled surface. ## ARIA diff --git a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx index 373b66c138c..59b28aea094 100644 --- a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx +++ b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx @@ -2,7 +2,7 @@ import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDialogContext } from './dialog-context'; /** Props for {@link DialogBackdrop}. */ @@ -14,20 +14,17 @@ export const DialogBackdrop = React.forwardRef; - return renderElement({ + return useRender({ defaultTagName: 'div', render, + enabled: mounted, + ref, state, stateAttributesMapping: { open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), diff --git a/packages/headless/src/primitives/dialog/dialog-close.tsx b/packages/headless/src/primitives/dialog/dialog-close.tsx index 1a126dada2f..23a388df9da 100644 --- a/packages/headless/src/primitives/dialog/dialog-close.tsx +++ b/packages/headless/src/primitives/dialog/dialog-close.tsx @@ -2,7 +2,7 @@ import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDialogContext } from './dialog-context'; /** Props for {@link DialogClose}. */ @@ -15,15 +15,15 @@ export const DialogClose = React.forwardRef const defaultProps = { type: 'button' as const, - ref, onClick() { setOpen(false); }, } satisfies DefaultProps<'button'>; - return renderElement({ + return useRender({ defaultTagName: 'button', render, + ref, props: mergeProps<'button'>(defaultProps, otherProps), }); }); diff --git a/packages/headless/src/primitives/dialog/dialog-description.tsx b/packages/headless/src/primitives/dialog/dialog-description.tsx index 81637b0b4be..c130e81a50c 100644 --- a/packages/headless/src/primitives/dialog/dialog-description.tsx +++ b/packages/headless/src/primitives/dialog/dialog-description.tsx @@ -2,7 +2,7 @@ import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDialogContext } from './dialog-context'; /** Props for {@link DialogDescription}. */ @@ -16,12 +16,12 @@ export const DialogDescription = React.forwardRef; - return renderElement({ + return useRender({ defaultTagName: 'p', render, + ref, props: mergeProps<'p'>(defaultProps, otherProps), }); }, diff --git a/packages/headless/src/primitives/dialog/dialog-title.tsx b/packages/headless/src/primitives/dialog/dialog-title.tsx index 875559defd7..323c17b3d5f 100644 --- a/packages/headless/src/primitives/dialog/dialog-title.tsx +++ b/packages/headless/src/primitives/dialog/dialog-title.tsx @@ -2,7 +2,7 @@ import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDialogContext } from './dialog-context'; /** Props for {@link DialogTitle}. */ @@ -15,12 +15,12 @@ export const DialogTitle = React.forwardRef; - return renderElement({ + return useRender({ defaultTagName: 'h2', render, + ref, props: mergeProps<'h2'>(defaultProps, otherProps), }); }); diff --git a/packages/headless/src/primitives/dialog/dialog-viewport.tsx b/packages/headless/src/primitives/dialog/dialog-viewport.tsx index bab6325ccdf..00303b17604 100644 --- a/packages/headless/src/primitives/dialog/dialog-viewport.tsx +++ b/packages/headless/src/primitives/dialog/dialog-viewport.tsx @@ -3,7 +3,7 @@ import { FloatingOverlay } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDialogContext } from './dialog-context'; /** Props for {@link DialogViewport}. */ @@ -25,33 +25,35 @@ export const DialogViewport = React.forwardRef; + const element = useRender({ + defaultTagName: 'div', + render, + enabled: mounted, + ref, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'div'>(defaultProps, otherProps), + }); + + if (!element) { + return null; + } + return ( - {renderElement({ - defaultTagName: 'div', - render, - state, - stateAttributesMapping: { - open: (v: boolean): Record | null => - v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }, - }, - props: mergeProps<'div'>(defaultProps, otherProps), - })} + {element} ); }, diff --git a/packages/headless/src/primitives/drawer/drawer-backdrop.tsx b/packages/headless/src/primitives/drawer/drawer-backdrop.tsx index 33a40fdb63c..e82b6d6a7d9 100644 --- a/packages/headless/src/primitives/drawer/drawer-backdrop.tsx +++ b/packages/headless/src/primitives/drawer/drawer-backdrop.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { DrawerAttrs } from './css-vars'; import { useDrawerContext } from './drawer-context'; @@ -16,22 +15,17 @@ export const DrawerBackdrop = React.forwardRef; - return renderElement({ + return useRender({ defaultTagName: 'div', render, + enabled: mounted, + ref: [backdropRef, ref], state, stateAttributesMapping: { open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), diff --git a/packages/headless/src/primitives/drawer/drawer-close.tsx b/packages/headless/src/primitives/drawer/drawer-close.tsx index cb9b1a43845..80f7ac42dd5 100644 --- a/packages/headless/src/primitives/drawer/drawer-close.tsx +++ b/packages/headless/src/primitives/drawer/drawer-close.tsx @@ -2,7 +2,7 @@ import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDrawerContext } from './drawer-context'; /** Props for {@link DrawerClose}. */ @@ -15,15 +15,15 @@ export const DrawerClose = React.forwardRef const defaultProps = { type: 'button' as const, - ref, onClick() { setOpen(false); }, } satisfies DefaultProps<'button'>; - return renderElement({ + return useRender({ defaultTagName: 'button', render, + ref, props: mergeProps<'button'>(defaultProps, otherProps), }); }); diff --git a/packages/headless/src/primitives/drawer/drawer-description.tsx b/packages/headless/src/primitives/drawer/drawer-description.tsx index 347e4cd9896..4b65ccd859d 100644 --- a/packages/headless/src/primitives/drawer/drawer-description.tsx +++ b/packages/headless/src/primitives/drawer/drawer-description.tsx @@ -2,7 +2,7 @@ import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDrawerContext } from './drawer-context'; /** Props for {@link DrawerDescription}. */ @@ -16,12 +16,12 @@ export const DrawerDescription = React.forwardRef; - return renderElement({ + return useRender({ defaultTagName: 'p', render, + ref, props: mergeProps<'p'>(defaultProps, otherProps), }); }, diff --git a/packages/headless/src/primitives/drawer/drawer-handle-grip.tsx b/packages/headless/src/primitives/drawer/drawer-handle-grip.tsx index ebd380ff0dc..2e3369b6054 100644 --- a/packages/headless/src/primitives/drawer/drawer-handle-grip.tsx +++ b/packages/headless/src/primitives/drawer/drawer-handle-grip.tsx @@ -2,7 +2,7 @@ import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { DrawerAttrs } from './css-vars'; /** Props for {@link DrawerHandleGrip}. */ @@ -20,13 +20,13 @@ export const DrawerHandleGrip = React.forwardRef; - return renderElement({ + return useRender({ defaultTagName: 'div', render, + ref, props: mergeProps<'div'>(defaultProps, otherProps), }); }, diff --git a/packages/headless/src/primitives/drawer/drawer-popup.tsx b/packages/headless/src/primitives/drawer/drawer-popup.tsx index e90a8ae6c07..77df11eedd3 100644 --- a/packages/headless/src/primitives/drawer/drawer-popup.tsx +++ b/packages/headless/src/primitives/drawer/drawer-popup.tsx @@ -1,9 +1,9 @@ 'use client'; -import { FloatingFocusManager, useMergeRefs } from '@floating-ui/react'; +import { FloatingFocusManager } from '@floating-ui/react'; import React, { useEffect } from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { DrawerAttrs, DrawerCssVars } from './css-vars'; import { useDrawerContext } from './drawer-context'; @@ -37,12 +37,6 @@ export const DrawerPopup = React.forwardRef(fu nestedOpenCount, } = useDrawerContext(); - // floating-ui types `setFloating` as a method signature, but at runtime it's - // a stable callback that doesn't use `this`, so the unbound-method check is a - // false positive here. - // eslint-disable-next-line @typescript-eslint/unbound-method - const combinedRef = useMergeRefs([popupRef, refs.setFloating, ref]); - // The nested-child count is a raw CSS input for the styled stack math. Written // imperatively (a `--*` custom property) rather than via React inline style. useEffect(() => { @@ -58,12 +52,7 @@ export const DrawerPopup = React.forwardRef(fu popupRef.current?.style.setProperty(DrawerCssVars.snapOffset, `${snapRestOffset}px`); }, [popupRef, snapRestOffset]); - if (!mounted) { - return null; - } - const ownProps = { - ref: combinedRef, tabIndex: -1, 'aria-labelledby': labelId, 'aria-describedby': descriptionId, @@ -88,6 +77,30 @@ export const DrawerPopup = React.forwardRef(fu nestedOpen: nestedOpenCount > 0, }; + const element = useRender({ + defaultTagName: 'div', + render, + enabled: mounted, + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + ref: [popupRef, refs.setFloating, ref], + state, + stateAttributesMapping: { + swiping: (v): Record | null => (v ? { [DrawerAttrs.swiping]: '' } : null), + snap: (v): Record | null => (v === null ? null : { [DrawerAttrs.snap]: String(v) }), + expanded: (v): Record | null => (v ? { [DrawerAttrs.expanded]: '' } : null), + nested: (v): Record | null => (v ? { [DrawerAttrs.nested]: '' } : null), + nestedOpen: (v): Record | null => (v ? { [DrawerAttrs.nestedOpen]: '' } : null), + }, + props: mergeProps<'div'>(defaultProps, otherProps), + }); + + if (!element) { + return null; + } + return ( (fu outsideElementsInert={modal} initialFocus={autoFocus ? undefined : popupRef} > - {renderElement({ - defaultTagName: 'div', - render, - state, - stateAttributesMapping: { - swiping: (v): Record | null => (v ? { [DrawerAttrs.swiping]: '' } : null), - snap: (v): Record | null => (v === null ? null : { [DrawerAttrs.snap]: String(v) }), - expanded: (v): Record | null => (v ? { [DrawerAttrs.expanded]: '' } : null), - nested: (v): Record | null => (v ? { [DrawerAttrs.nested]: '' } : null), - nestedOpen: (v): Record | null => (v ? { [DrawerAttrs.nestedOpen]: '' } : null), - }, - props: mergeProps<'div'>(defaultProps, otherProps), - })} + {element} ); }); diff --git a/packages/headless/src/primitives/drawer/drawer-title.tsx b/packages/headless/src/primitives/drawer/drawer-title.tsx index bceb9385030..6ae6b37555d 100644 --- a/packages/headless/src/primitives/drawer/drawer-title.tsx +++ b/packages/headless/src/primitives/drawer/drawer-title.tsx @@ -2,7 +2,7 @@ import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDrawerContext } from './drawer-context'; /** Props for {@link DrawerTitle}. */ @@ -15,12 +15,12 @@ export const DrawerTitle = React.forwardRef; - return renderElement({ + return useRender({ defaultTagName: 'h2', render, + ref, props: mergeProps<'h2'>(defaultProps, otherProps), }); }); diff --git a/packages/headless/src/primitives/drawer/drawer-trigger.tsx b/packages/headless/src/primitives/drawer/drawer-trigger.tsx index 9c52f96e1d0..4b826833fd0 100644 --- a/packages/headless/src/primitives/drawer/drawer-trigger.tsx +++ b/packages/headless/src/primitives/drawer/drawer-trigger.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React, { useCallback, useContext, useSyncExternalStore } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { DrawerContext } from './drawer-context'; import type { DrawerHandle } from './drawer-handle'; @@ -35,12 +34,6 @@ export const DrawerTrigger = React.forwardRef handle?.isOpen ?? false, ); - // floating-ui types `setReference` as a method signature, but at runtime it's - // a stable callback that doesn't use `this`, so the unbound-method check is a - // false positive here. - // eslint-disable-next-line @typescript-eslint/unbound-method - const combinedRef = useMergeRefs([ctx?.refs.setReference, ref]); - if (!handle && !ctx) { throw new Error('Drawer.Trigger must be used within or be given a `handle`'); } @@ -50,14 +43,25 @@ export const DrawerTrigger = React.forwardRef handle.toggle(), } - : { type: 'button' as const, ref: combinedRef, ...ctx?.getReferenceProps() }; + : { type: 'button' as const, ...ctx?.getReferenceProps() }; + + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + const finalRef = handle + ? ref + : [ + // eslint-disable-next-line @typescript-eslint/unbound-method + ctx?.refs.setReference, + ref, + ]; - return renderElement({ + return useRender({ defaultTagName: 'button', render, + ref: finalRef, state: { open }, stateAttributesMapping: { open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), diff --git a/packages/headless/src/primitives/drawer/drawer-viewport.tsx b/packages/headless/src/primitives/drawer/drawer-viewport.tsx index 8d74cf801c0..cb3cfee1422 100644 --- a/packages/headless/src/primitives/drawer/drawer-viewport.tsx +++ b/packages/headless/src/primitives/drawer/drawer-viewport.tsx @@ -3,7 +3,7 @@ import { FloatingOverlay } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDrawerContext } from './drawer-context'; /** Props for {@link DrawerViewport}. */ @@ -23,33 +23,35 @@ export const DrawerViewport = React.forwardRef; + const element = useRender({ + defaultTagName: 'div', + render, + enabled: mounted, + ref, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'div'>(defaultProps, otherProps), + }); + + if (!element) { + return null; + } + return ( - {renderElement({ - defaultTagName: 'div', - render, - state, - stateAttributesMapping: { - open: (v: boolean): Record | null => - v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }, - }, - props: mergeProps<'div'>(defaultProps, otherProps), - })} + {element} ); }, diff --git a/packages/headless/src/primitives/file-upload/file-upload-dropzone.tsx b/packages/headless/src/primitives/file-upload/file-upload-dropzone.tsx index 378ef383024..1da90680ea9 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-dropzone.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-dropzone.tsx @@ -2,7 +2,7 @@ import { type DragEvent, useRef, useState } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useFileUploadContext } from './file-upload-context'; export type FileUploadDropzoneProps = ComponentProps<'div'>; @@ -61,7 +61,7 @@ export function FileUploadDropzone(props: FileUploadDropzoneProps) { }, }; - return renderElement({ + return useRender({ defaultTagName: 'div', render, state, diff --git a/packages/headless/src/primitives/file-upload/file-upload-item-delete.tsx b/packages/headless/src/primitives/file-upload/file-upload-item-delete.tsx index eb571e7bcd6..3e8138d5680 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-item-delete.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-item-delete.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useFileUploadContext, useFileUploadItemContext } from './file-upload-context'; export type FileUploadItemDeleteProps = ComponentProps<'button'>; @@ -23,7 +23,7 @@ export function FileUploadItemDelete(props: FileUploadItemDeleteProps) { }, }; - return renderElement({ + return useRender({ defaultTagName: 'button', render, state, diff --git a/packages/headless/src/primitives/file-upload/file-upload-item-preview.tsx b/packages/headless/src/primitives/file-upload/file-upload-item-preview.tsx index b5b6f303542..bdd30807d6e 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-item-preview.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-item-preview.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useFileUploadItemContext } from './file-upload-context'; export type FileUploadItemPreviewProps = ComponentProps<'img'>; @@ -36,12 +36,12 @@ export const FileUploadItemPreview = React.forwardRef with an empty src. enabled: isImage && objectUrl != null, diff --git a/packages/headless/src/primitives/file-upload/file-upload-item.tsx b/packages/headless/src/primitives/file-upload/file-upload-item.tsx index 3a1123d5adc..8706328f387 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-item.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-item.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { FileUploadItemContext, type FileUploadItemContextValue, useFileUploadContext } from './file-upload-context'; export interface FileUploadItemProps extends ComponentProps<'div'> { @@ -26,7 +26,7 @@ export function FileUploadItem(props: FileUploadItemProps) { return ( - {renderElement({ + {useRender({ defaultTagName: 'div', render, state, diff --git a/packages/headless/src/primitives/file-upload/file-upload-root.tsx b/packages/headless/src/primitives/file-upload/file-upload-root.tsx index eb821e02e6b..48458999567 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-root.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-root.tsx @@ -3,7 +3,7 @@ import { type CSSProperties, type ReactNode, useCallback, useId, useMemo, useRef } from 'react'; import { useControllableState } from '../../hooks/use-controllable-state'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { isFileAccepted } from './accept'; import { FileUploadContext, type FileUploadContextValue } from './file-upload-context'; @@ -170,7 +170,7 @@ export function FileUploadRoot(props: FileUploadProps) { return ( - {renderElement({ + {useRender({ defaultTagName: 'div', render, state, diff --git a/packages/headless/src/primitives/file-upload/file-upload-trigger.tsx b/packages/headless/src/primitives/file-upload/file-upload-trigger.tsx index 558e9454469..3eefb0a1356 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-trigger.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-trigger.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useFileUploadContext } from './file-upload-context'; export type FileUploadTriggerProps = ComponentProps<'button'>; @@ -18,7 +18,7 @@ export function FileUploadTrigger(props: FileUploadTriggerProps) { onClick: () => openFilePicker(), }; - return renderElement({ + return useRender({ defaultTagName: 'button', render, state, diff --git a/packages/headless/src/primitives/menu/menu-item.tsx b/packages/headless/src/primitives/menu/menu-item.tsx index 26d74829c6a..8376f85c53c 100644 --- a/packages/headless/src/primitives/menu/menu-item.tsx +++ b/packages/headless/src/primitives/menu/menu-item.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useFloatingTree, useListItem, useMergeRefs } from '@floating-ui/react'; +import { useFloatingTree, useListItem } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useMenuContext } from './menu-context'; export interface MenuItemProps extends ComponentProps<'button'> { @@ -24,8 +24,6 @@ export const MenuItem = React.forwardRef(funct const item = useListItem({ label: disabled ? null : label }); const isActive = item.index === activeIndex; - const combinedRef = useMergeRefs([item.ref, ref]); - const state = { active: isActive, disabled: !!disabled, @@ -34,7 +32,6 @@ export const MenuItem = React.forwardRef(funct const ownProps = { 'data-cl-slot': 'menu-item', type: 'button', - ref: combinedRef, role: 'menuitem', tabIndex: isActive ? 0 : -1, ...(disabled && { 'aria-disabled': true }), @@ -51,9 +48,10 @@ export const MenuItem = React.forwardRef(funct }), }; - return renderElement({ + return useRender({ defaultTagName: 'button', render, + ref: [item.ref, ref], state, stateAttributesMapping: { active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), diff --git a/packages/headless/src/primitives/menu/menu-popup.tsx b/packages/headless/src/primitives/menu/menu-popup.tsx index 22e5f6ca3c2..94cc628d1e1 100644 --- a/packages/headless/src/primitives/menu/menu-popup.tsx +++ b/packages/headless/src/primitives/menu/menu-popup.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useMenuContext } from './menu-context'; export type MenuPopupProps = ComponentProps<'div'>; @@ -12,17 +11,15 @@ export const MenuPopup = React.forwardRef(functi const { render, ...otherProps } = props; const { popupRef, transitionProps } = useMenuContext(); - const combinedRef = useMergeRefs([popupRef, ref]); - const defaultProps = { 'data-cl-slot': 'menu-popup', - ref: combinedRef, ...transitionProps, }; - return renderElement({ + return useRender({ defaultTagName: 'div', render, + ref: [popupRef, ref], props: mergeProps<'div'>(defaultProps, otherProps), }); }); diff --git a/packages/headless/src/primitives/menu/menu-positioner.tsx b/packages/headless/src/primitives/menu/menu-positioner.tsx index db1bab027e4..844e1290f66 100644 --- a/packages/headless/src/primitives/menu/menu-positioner.tsx +++ b/packages/headless/src/primitives/menu/menu-positioner.tsx @@ -1,9 +1,9 @@ 'use client'; -import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react'; +import { FloatingFocusManager, FloatingList } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useMenuContext } from './menu-context'; export type MenuPositionerProps = ComponentProps<'div'>; @@ -24,12 +24,6 @@ export const MenuPositioner = React.forwardRef; const defaultProps = { ...ownProps, ...floatingProps }; - if (!mounted) { - return null; - } - const merged = mergeProps<'div'>(defaultProps, otherProps); // The menu id is owned by floating-ui's menu role: a consumer-supplied id must // not override it, or the trigger's aria-controls pairing would silently break. @@ -75,12 +64,22 @@ export const MenuPositioner = React.forwardRef; @@ -12,7 +12,7 @@ export function MenuSeparator(props: MenuSeparatorProps) { role: 'separator' as const, }; - return renderElement({ + return useRender({ defaultTagName: 'div', render, props: mergeProps<'div'>(defaultProps, otherProps), diff --git a/packages/headless/src/primitives/menu/menu-trigger.tsx b/packages/headless/src/primitives/menu/menu-trigger.tsx index c639be8caac..c6dc10f3c2c 100644 --- a/packages/headless/src/primitives/menu/menu-trigger.tsx +++ b/packages/headless/src/primitives/menu/menu-trigger.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useListItem, useMergeRefs } from '@floating-ui/react'; +import { useListItem } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useMenuContext } from './menu-context'; export type MenuTriggerProps = ComponentProps<'button'>; @@ -14,12 +14,6 @@ export const MenuTrigger = React.forwardRef const item = useListItem(); - // floating-ui types `setReference` as a method signature, but at runtime it's - // a stable callback that doesn't use `this`, so the unbound-method check is a - // false positive here. - // eslint-disable-next-line @typescript-eslint/unbound-method - const mergedRef = useMergeRefs([refs.setReference, isNested ? item.ref : null, ref ?? null]); - const state = { open }; let referenceProps: Record; @@ -33,7 +27,6 @@ export const MenuTrigger = React.forwardRef const ownProps = { type: 'button', 'data-cl-slot': 'menu-trigger', - ref: mergedRef, ...(isNested && { role: 'menuitem', tabIndex: parentContext?.activeIndex === item.index ? 0 : -1, @@ -42,9 +35,14 @@ export const MenuTrigger = React.forwardRef const defaultProps = { ...ownProps, ...referenceProps }; - return renderElement({ + return useRender({ defaultTagName: 'button', render, + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + ref: [refs.setReference, isNested ? item.ref : null, ref ?? null], state, stateAttributesMapping: { open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), diff --git a/packages/headless/src/primitives/otp/otp-input.tsx b/packages/headless/src/primitives/otp/otp-input.tsx index 347dc22db03..3486a9fb856 100644 --- a/packages/headless/src/primitives/otp/otp-input.tsx +++ b/packages/headless/src/primitives/otp/otp-input.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React, { useCallback } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useOtpContext } from './otp-context'; import { inputModeForPattern, removeAt, replaceAt, sanitize } from './otp-utils'; @@ -36,8 +35,6 @@ export const OtpInput = React.forwardRef(functi element => registerInput(index, element), [registerInput, index], ); - // Compose our slot registration with any ref the consumer forwards. - const setRef = useMergeRefs([registerRef, forwardedRef]); // Roving tab order: the focused slot is the tab stop, or the next empty slot // when the field is unfocused, so Tab enters and leaves the group once. @@ -47,7 +44,6 @@ export const OtpInput = React.forwardRef(functi const defaultProps: Record = { 'data-cl-slot': 'otp-input', - ref: setRef, value: char, type: mask ? 'password' : 'text', inputMode: inputModeForPattern(pattern), @@ -202,9 +198,10 @@ export const OtpInput = React.forwardRef(functi }, }; - return renderElement({ + return useRender({ defaultTagName: 'input', render, + ref: [registerRef, forwardedRef], state, stateAttributesMapping: { active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), diff --git a/packages/headless/src/primitives/otp/otp-root.tsx b/packages/headless/src/primitives/otp/otp-root.tsx index 27dea3b67c6..5d55aa99c35 100644 --- a/packages/headless/src/primitives/otp/otp-root.tsx +++ b/packages/headless/src/primitives/otp/otp-root.tsx @@ -3,7 +3,7 @@ import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useControllableState } from '../../hooks/use-controllable-state'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { OtpContext, type OtpContextValue, type OtpSlot } from './otp-context'; import { inputModeForPattern, type OtpPattern, sanitize } from './otp-utils'; @@ -200,7 +200,7 @@ export function OtpRoot(props: OtpProps) { return ( - {renderElement({ + {useRender({ defaultTagName: 'div', render, state, diff --git a/packages/headless/src/primitives/popover/popover-close.tsx b/packages/headless/src/primitives/popover/popover-close.tsx index 545ee097df6..89c968a4166 100644 --- a/packages/headless/src/primitives/popover/popover-close.tsx +++ b/packages/headless/src/primitives/popover/popover-close.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { usePopoverContext } from './popover-context'; export type PopoverCloseProps = ComponentProps<'button'>; @@ -17,7 +17,7 @@ export function PopoverClose(props: PopoverCloseProps) { }, }; - return renderElement({ + return useRender({ defaultTagName: 'button', render, props: mergeProps<'button'>(defaultProps, otherProps), diff --git a/packages/headless/src/primitives/popover/popover-description.tsx b/packages/headless/src/primitives/popover/popover-description.tsx index 598de543142..e6b3a6788fe 100644 --- a/packages/headless/src/primitives/popover/popover-description.tsx +++ b/packages/headless/src/primitives/popover/popover-description.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { usePopoverContext } from './popover-context'; export type PopoverDescriptionProps = Omit, 'id'>; @@ -21,7 +21,7 @@ export function PopoverDescription(props: PopoverDescriptionProps) { id: descriptionId, }; - return renderElement({ + return useRender({ defaultTagName: 'p', render, props: mergeProps<'p'>(defaultProps, otherProps), diff --git a/packages/headless/src/primitives/popover/popover-popup.tsx b/packages/headless/src/primitives/popover/popover-popup.tsx index 197ebfd9a5a..eb73b96ffe4 100644 --- a/packages/headless/src/primitives/popover/popover-popup.tsx +++ b/packages/headless/src/primitives/popover/popover-popup.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { usePopoverContext } from './popover-context'; export type PopoverPopupProps = ComponentProps<'div'>; @@ -12,17 +11,15 @@ export const PopoverPopup = React.forwardRef( const { render, ...otherProps } = props; const { popupRef, transitionProps } = usePopoverContext(); - const combinedRef = useMergeRefs([popupRef, ref]); - const defaultProps = { 'data-cl-slot': 'popover-popup', - ref: combinedRef, ...transitionProps, }; - return renderElement({ + return useRender({ defaultTagName: 'div', render, + ref: [popupRef, ref], props: mergeProps<'div'>(defaultProps, otherProps), }); }); diff --git a/packages/headless/src/primitives/popover/popover-positioner.tsx b/packages/headless/src/primitives/popover/popover-positioner.tsx index 3a07a70519f..5b65eea3bac 100644 --- a/packages/headless/src/primitives/popover/popover-positioner.tsx +++ b/packages/headless/src/primitives/popover/popover-positioner.tsx @@ -1,9 +1,9 @@ 'use client'; -import { FloatingFocusManager, useMergeRefs } from '@floating-ui/react'; +import { FloatingFocusManager } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { usePopoverContext } from './popover-context'; export type PopoverPositionerProps = ComponentProps<'div'>; @@ -27,16 +27,9 @@ export const PopoverPositioner = React.forwardRef(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/popover/popover-title.tsx b/packages/headless/src/primitives/popover/popover-title.tsx index 62d3605d007..66c7b681d22 100644 --- a/packages/headless/src/primitives/popover/popover-title.tsx +++ b/packages/headless/src/primitives/popover/popover-title.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { usePopoverContext } from './popover-context'; export type PopoverTitleProps = Omit, 'id'>; @@ -21,7 +21,7 @@ export function PopoverTitle(props: PopoverTitleProps) { id: labelId, }; - return renderElement({ + return useRender({ defaultTagName: 'h2', render, props: mergeProps<'h2'>(defaultProps, otherProps), diff --git a/packages/headless/src/primitives/popover/popover-trigger.tsx b/packages/headless/src/primitives/popover/popover-trigger.tsx index b4b5bc8f17c..b96dffa15d6 100644 --- a/packages/headless/src/primitives/popover/popover-trigger.tsx +++ b/packages/headless/src/primitives/popover/popover-trigger.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { usePopoverContext } from './popover-context'; export type PopoverTriggerProps = ComponentProps<'button'>; @@ -13,25 +12,23 @@ export const PopoverTrigger = React.forwardRef; const defaultProps = { ...ownProps, ...getReferenceProps() }; - return renderElement({ + return useRender({ defaultTagName: 'button', render, + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + ref: [refs.setReference, ref], state, stateAttributesMapping: { open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), diff --git a/packages/headless/src/primitives/select/select-option.tsx b/packages/headless/src/primitives/select/select-option.tsx index f93a27b0c07..a2be6bc8d16 100644 --- a/packages/headless/src/primitives/select/select-option.tsx +++ b/packages/headless/src/primitives/select/select-option.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useListItem, useMergeRefs } from '@floating-ui/react'; +import { useListItem } from '@floating-ui/react'; import { useEffect } from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useSelectContext } from './select-context'; export interface SelectOptionProps extends ComponentProps<'button'> { @@ -37,8 +37,6 @@ export function SelectOption(props: SelectOptionProps) { } }, [index, isSelected, setSelectedIndex]); - const combinedRef = useMergeRefs([itemRef, isSelected ? selectedItemRef : null]); - const state = { selected: isSelected, active: isActive, @@ -48,7 +46,6 @@ export function SelectOption(props: SelectOptionProps) { const ownProps = { 'data-cl-slot': 'select-option', type: 'button', - ref: combinedRef, role: 'option', 'aria-selected': isSelected, 'aria-disabled': disabled || undefined, @@ -66,7 +63,7 @@ export function SelectOption(props: SelectOptionProps) { }), }; - return renderElement({ + return useRender({ defaultTagName: 'button', render, state, @@ -75,6 +72,7 @@ export function SelectOption(props: SelectOptionProps) { active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), }, + ref: [itemRef, isSelected ? selectedItemRef : null], props: mergeProps<'button'>(defaultProps, otherProps), }); } diff --git a/packages/headless/src/primitives/select/select-popup.tsx b/packages/headless/src/primitives/select/select-popup.tsx index 9d8ba64bc17..10fb3f0dd09 100644 --- a/packages/headless/src/primitives/select/select-popup.tsx +++ b/packages/headless/src/primitives/select/select-popup.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useSelectContext } from './select-context'; export type SelectPopupProps = ComponentProps<'div'>; @@ -12,17 +11,15 @@ export const SelectPopup = React.forwardRef(fu const { render, ...otherProps } = props; const { popupRef, transitionProps } = useSelectContext(); - const combinedRef = useMergeRefs([popupRef, ref]); - const defaultProps = { 'data-cl-slot': 'select-popup', - ref: combinedRef, ...transitionProps, }; - return renderElement({ + return useRender({ defaultTagName: 'div', render, + ref: [popupRef, ref], props: mergeProps<'div'>(defaultProps, otherProps), }); }); diff --git a/packages/headless/src/primitives/select/select-positioner.tsx b/packages/headless/src/primitives/select/select-positioner.tsx index d63b4a610ac..0586c987b3f 100644 --- a/packages/headless/src/primitives/select/select-positioner.tsx +++ b/packages/headless/src/primitives/select/select-positioner.tsx @@ -1,9 +1,9 @@ 'use client'; -import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react'; +import { FloatingFocusManager, FloatingList } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useSelectContext } from './select-context'; export type SelectPositionerProps = ComponentProps<'div'>; @@ -25,12 +25,6 @@ export const SelectPositioner = React.forwardRef) { if (event.key === 'Home' || event.key === 'End') { @@ -57,7 +51,6 @@ export const SelectPositioner = React.forwardRef; @@ -71,6 +64,22 @@ export const SelectPositioner = React.forwardRef - {renderElement({ - defaultTagName: 'div', - render, - enabled: mounted, - props: merged, - })} + {element} ); diff --git a/packages/headless/src/primitives/select/select-trigger.tsx b/packages/headless/src/primitives/select/select-trigger.tsx index c02bccb4ab9..8b5ada7ce89 100644 --- a/packages/headless/src/primitives/select/select-trigger.tsx +++ b/packages/headless/src/primitives/select/select-trigger.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useSelectContext } from './select-context'; export type SelectTriggerProps = ComponentProps<'button'>; @@ -13,29 +12,27 @@ export const SelectTrigger = React.forwardRef; const defaultProps = { ...ownProps, ...getReferenceProps() }; - return renderElement({ + return useRender({ defaultTagName: 'button', render, state, stateAttributesMapping: { open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), }, + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + ref: [refs.setReference, ref], props: mergeProps<'button'>(defaultProps, otherProps), }); }, diff --git a/packages/headless/src/primitives/select/select-value.tsx b/packages/headless/src/primitives/select/select-value.tsx index 8c28ece29d8..dc95462efe1 100644 --- a/packages/headless/src/primitives/select/select-value.tsx +++ b/packages/headless/src/primitives/select/select-value.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import type { SelectItem } from './select-context'; import { useSelectContext } from './select-context'; @@ -43,7 +43,7 @@ export function SelectValue(props: SelectValueProps) { children: displayText, }; - return renderElement({ + return useRender({ defaultTagName: 'span', render, props: mergeProps<'span'>(defaultProps, otherProps), diff --git a/packages/headless/src/primitives/tabs/tabs-indicator.tsx b/packages/headless/src/primitives/tabs/tabs-indicator.tsx index 7e8d5788fea..13aabfc5d9d 100644 --- a/packages/headless/src/primitives/tabs/tabs-indicator.tsx +++ b/packages/headless/src/primitives/tabs/tabs-indicator.tsx @@ -3,7 +3,7 @@ import type React from 'react'; import { useLayoutEffect, useRef, useState } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useTabsContext } from './tabs-context'; export type TabsIndicatorProps = ComponentProps<'span'>; @@ -74,7 +74,7 @@ export function TabsIndicator(props: TabsIndicatorProps) { style, }; - return renderElement({ + return useRender({ defaultTagName: 'span', render, props: mergeProps<'span'>(defaultProps, otherProps), diff --git a/packages/headless/src/primitives/tabs/tabs-list.tsx b/packages/headless/src/primitives/tabs/tabs-list.tsx index a9aa700a890..93e4dd2467c 100644 --- a/packages/headless/src/primitives/tabs/tabs-list.tsx +++ b/packages/headless/src/primitives/tabs/tabs-list.tsx @@ -3,7 +3,7 @@ import { Composite } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useTabsContext } from './tabs-context'; export type TabsListProps = ComponentProps<'div'>; @@ -39,10 +39,22 @@ export function TabsList(props: TabsListProps) { mergeProps<'div'>(otherProps, compositeProps as Record), ); - return renderElement({ + // Composite may inject a ref via compositeProps; hand it to useRender's ref + // param (which owns ref-merging) instead of leaving it in props, where + // useRender's merged ref would overwrite it. + const { ref: compositeRef, ...mergedProps } = merged; + + // floating-ui's Composite invokes this render callback synchronously and + // unconditionally during its own render (see renderJsx), so useRender runs in a + // stable hook position on the Composite fiber. The rule can't see that. + // eslint-disable-next-line react-hooks/rules-of-hooks + return useRender({ defaultTagName: 'div', render, - props: merged, + // SAFETY: mergeProps returns Record; a ref Composite injected + // is a valid React ref at runtime. + ref: compositeRef as React.Ref, + props: mergedProps, }); }} > diff --git a/packages/headless/src/primitives/tabs/tabs-panel.tsx b/packages/headless/src/primitives/tabs/tabs-panel.tsx index 9bc22eef12f..bd5593e3a28 100644 --- a/packages/headless/src/primitives/tabs/tabs-panel.tsx +++ b/packages/headless/src/primitives/tabs/tabs-panel.tsx @@ -1,11 +1,10 @@ 'use client'; import { inertProps } from '@clerk/shared/inert'; -import { useMergeRefs } from '@floating-ui/react'; import React, { useRef } from 'react'; import { useTransition } from '../../hooks/use-transition'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useTabsContext } from './tabs-context'; export interface TabsPanelProps extends ComponentProps<'div'> { @@ -23,9 +22,6 @@ export const TabsPanel = React.forwardRef(functi const panelId = `${tabsId}-panel-${panelValue}`; const panelRef = useRef(null); - // Merge the consumer ref with the internal panelRef so passing a ref does not - // clobber the ref the panel relies on for transition tracking. - const combinedRef = useMergeRefs([panelRef, ref]); const { transitionProps } = useTransition({ open: isSelected, ref: panelRef, @@ -54,7 +50,6 @@ export const TabsPanel = React.forwardRef(functi tabIndex: 0, ...inertProps(!isSelected), hidden: !isSelected && !shouldForceMount ? true : undefined, - ref: combinedRef, ...(shouldForceMount ? { ...effectiveTransitionProps, @@ -68,9 +63,10 @@ export const TabsPanel = React.forwardRef(functi // override it, or the tab/panel aria pairing would silently break. merged.id = panelId; - return renderElement({ + return useRender({ defaultTagName: 'div', render, + ref: [panelRef, ref], state, stateAttributesMapping: { hidden: (v: boolean) => (v ? { 'data-cl-hidden': '' } : null), diff --git a/packages/headless/src/primitives/tabs/tabs-tab.tsx b/packages/headless/src/primitives/tabs/tabs-tab.tsx index e610e91c188..3a3bc7fb5be 100644 --- a/packages/headless/src/primitives/tabs/tabs-tab.tsx +++ b/packages/headless/src/primitives/tabs/tabs-tab.tsx @@ -1,9 +1,9 @@ 'use client'; -import { CompositeItem, useMergeRefs } from '@floating-ui/react'; +import { CompositeItem } from '@floating-ui/react'; import React, { useLayoutEffect, useRef } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useTabsContext } from './tabs-context'; export interface TabsTabProps extends ComponentProps<'button'> { @@ -19,7 +19,6 @@ export const TabsTab = React.forwardRef(functio const tabId = `${tabsId}-tab-${tabValue}`; const panelId = `${tabsId}-panel-${tabValue}`; const internalRef = useRef(null); - const combinedRef = useMergeRefs([internalRef, ref]); useLayoutEffect(() => { registerTab(tabValue, internalRef.current); @@ -33,7 +32,7 @@ export const TabsTab = React.forwardRef(functio return ( ) => { const defaultProps: Record = { @@ -77,15 +76,27 @@ export const TabsTab = React.forwardRef(functio // override it, or the tab/panel aria pairing would silently break. merged.id = tabId; - return renderElement({ + // CompositeItem injects its roving-tabindex ref via compositeProps; hand it to + // useRender's ref param (which owns ref-merging) instead of leaving it in props, + // where useRender's merged ref would overwrite it and break focus navigation. + const { ref: compositeRef, ...mergedProps } = merged; + + // floating-ui's CompositeItem invokes this render callback synchronously and + // unconditionally during its own render (see renderJsx), so useRender runs in a + // stable hook position on the CompositeItem fiber. The rule can't see that. + // eslint-disable-next-line react-hooks/rules-of-hooks + return useRender({ defaultTagName: 'button', render, + // SAFETY: mergeProps returns Record; the ref CompositeItem + // injected is a valid React ref at runtime. + ref: [internalRef, compositeRef as React.Ref, ref], state, stateAttributesMapping: { selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), }, - props: merged, + props: mergedProps, }); }} > diff --git a/packages/headless/src/primitives/tabs/tabs-trigger.tsx b/packages/headless/src/primitives/tabs/tabs-trigger.tsx index 74b4ef72ce9..e7fbbb96aac 100644 --- a/packages/headless/src/primitives/tabs/tabs-trigger.tsx +++ b/packages/headless/src/primitives/tabs/tabs-trigger.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React, { useLayoutEffect, useRef } from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useTabsContext } from './tabs-context'; export interface TabsTriggerProps extends ComponentProps<'button'> { @@ -15,7 +14,6 @@ export const TabsTrigger = React.forwardRef const { render, value: tabValue, disabled, ...otherProps } = props; const { value: selectedValue, setValue, tabsId, registerTab } = useTabsContext(); const triggerRef = useRef(null); - const combinedRef = useMergeRefs([triggerRef, ref]); const isSelected = selectedValue === tabValue; const tabId = `${tabsId}-tab-${tabValue}`; @@ -33,7 +31,6 @@ export const TabsTrigger = React.forwardRef const defaultProps = { 'data-cl-slot': 'tabs-trigger', - ref: combinedRef, id: tabId, role: 'tab' as const, type: 'button' as const, @@ -52,9 +49,10 @@ export const TabsTrigger = React.forwardRef // override it, or the tab/panel aria pairing would silently break. merged.id = tabId; - return renderElement({ + return useRender({ defaultTagName: 'button', render, + ref: [triggerRef, ref], state, stateAttributesMapping: { selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), diff --git a/packages/headless/src/primitives/tooltip/tooltip-popup.tsx b/packages/headless/src/primitives/tooltip/tooltip-popup.tsx index 8ba242a0c53..ef00ac8e71d 100644 --- a/packages/headless/src/primitives/tooltip/tooltip-popup.tsx +++ b/packages/headless/src/primitives/tooltip/tooltip-popup.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useTooltipContext } from './tooltip-context'; export type TooltipPopupProps = ComponentProps<'div'>; @@ -12,17 +11,15 @@ export const TooltipPopup = React.forwardRef( const { render, ...otherProps } = props; const { popupRef, transitionProps } = useTooltipContext(); - const combinedRef = useMergeRefs([popupRef, ref]); - const defaultProps = { 'data-cl-slot': 'tooltip-popup', - ref: combinedRef, ...transitionProps, }; - return renderElement({ + return useRender({ defaultTagName: 'div', render, + ref: [popupRef, ref], props: mergeProps<'div'>(defaultProps, otherProps), }); }); diff --git a/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx b/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx index 221ba845caf..963e5856c29 100644 --- a/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx +++ b/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useMergeRefs } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, renderElement } from '../../utils/render-element'; +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useTooltipContext } from './tooltip-context'; export type TooltipPositionerProps = ComponentProps<'div'>; @@ -13,12 +12,6 @@ export const TooltipPositioner = React.forwardRef; @@ -37,10 +29,15 @@ export const TooltipPositioner = React.forwardRef; @@ -13,29 +12,27 @@ export const TooltipTrigger = React.forwardRef; const defaultProps = { ...ownProps, ...getReferenceProps() }; - return renderElement({ + return useRender({ defaultTagName: 'button', render, state, stateAttributesMapping: { open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), }, + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + ref: [refs.setReference, ref], props: mergeProps<'button'>(defaultProps, otherProps), }); },