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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/headless-primitives-use-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
@@ -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'>;

Expand All @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions packages/headless/src/primitives/accordion/accordion-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'> {
Expand Down Expand Up @@ -34,7 +34,7 @@ export function AccordionItem(props: AccordionItemProps) {

return (
<AccordionItemContext.Provider value={itemContextValue}>
{renderElement({
{useRender({
defaultTagName: 'div',
render,
state,
Expand Down
10 changes: 3 additions & 7 deletions packages/headless/src/primitives/accordion/accordion-panel.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -17,9 +16,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const { open, triggerId, panelId } = useAccordionItemContext();

const panelRef = useRef<HTMLElement | null>(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<number | undefined>(undefined);

// Track whether open has ever transitioned from true→false.
Expand Down Expand Up @@ -108,7 +104,6 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
ref: combinedRef,
...effectiveTransitionProps,
style: {
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
Expand All @@ -121,10 +116,11 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
// override it, or the trigger/panel aria pairing would silently break.
merged.id = panelId;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
enabled: mounted,
ref: [panelRef, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'> {
Expand Down Expand Up @@ -79,10 +79,22 @@ export function AccordionRoot(props: AccordionProps) {
mergeProps<'div'>(otherProps, restCompositeProps as Record<string, unknown>),
);

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<string, unknown>; a ref Composite injected
// is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
props: mergedProps,
});
}}
>
Expand Down
18 changes: 15 additions & 3 deletions packages/headless/src/primitives/accordion/accordion-trigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'>;
Expand Down Expand Up @@ -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<string, unknown>; the ref CompositeItem
// injected is a valid React ref at runtime.
ref: compositeRef as React.Ref<unknown>,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null =>
v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: merged,
props: mergedProps,
});
}}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -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'>;
Expand All @@ -23,18 +22,11 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
valuesByIndexRef,
} = useAutocompleteContext();

// 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([refs.setReference, ref]);

const state = { open };

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...getReferenceProps({
ref: combinedRef,
value: inputValue,
'aria-autocomplete': 'list' as const,
onChange(event: React.ChangeEvent<HTMLInputElement>) {
Expand All @@ -53,9 +45,14 @@ export const AutocompleteInput = React.forwardRef<HTMLInputElement, Autocomplete
}),
};

return renderElement({
return useRender({
defaultTagName: 'input',
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<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
Original file line number Diff line number Diff line change
@@ -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'>;
Expand All @@ -18,18 +18,11 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
return () => 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 };
Expand All @@ -46,9 +39,14 @@ export const AutocompleteList = React.forwardRef<HTMLDivElement, AutocompleteLis
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{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,
})}
</FloatingList>
Expand Down
Original file line number Diff line number Diff line change
@@ -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'> {
Expand All @@ -21,7 +21,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const id = useId();
const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });
const combinedRef = useMergeRefs([itemRef, ref]);

const isSelected = selectedValue === value;
const isActive = activeIndex === index;
Expand All @@ -46,7 +45,6 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const ownProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: combinedRef,
role: 'option',
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
Expand All @@ -69,9 +67,10 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
// aria-activedescendant linkage: a consumer-supplied id must not override it.
merged.id = id;

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [itemRef, ref],
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
Expand Down
Original file line number Diff line number Diff line change
@@ -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'>;
Expand All @@ -13,17 +12,15 @@ export const AutocompletePopup = React.forwardRef<HTMLDivElement, AutocompletePo
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useAutocompleteContext();

const combinedRef = useMergeRefs([popupRef, ref]);

const defaultProps = {
'data-cl-slot': 'autocomplete-popup',
ref: combinedRef,
...transitionProps,
};

return renderElement({
return useRender({
defaultTagName: 'div',
render,
ref: [popupRef, ref],
props: mergeProps<'div'>(defaultProps, otherProps),
});
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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'>;
Expand All @@ -16,19 +16,12 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl

const side = placement.split('-')[0];

// 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-positioner',
'data-cl-side': side,
ref: combinedRef,
style: floatingStyles,
} satisfies DefaultProps<'div'>;

Expand All @@ -41,6 +34,22 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
merged.id = wiredId;
}

const element = 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],
enabled: mounted,
props: merged,
});

if (!element) {
return null;
}

return (
<FloatingFocusManager
context={floatingContext}
Expand All @@ -52,12 +61,7 @@ export const AutocompletePositioner = React.forwardRef<HTMLDivElement, Autocompl
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{renderElement({
defaultTagName: 'div',
render,
enabled: mounted,
props: merged,
})}
{element}
</FloatingList>
</FloatingFocusManager>
);
Expand Down
Loading
Loading