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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-use-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
37 changes: 19 additions & 18 deletions packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line number Diff line number Diff line change
@@ -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';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogPopup}. */
Expand All @@ -15,35 +15,36 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
const { popupRef, refs, getFloatingProps, floatingContext, modal, labelId, descriptionId, mounted, transitionProps } =
useDialogContext();

// 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]);

if (!mounted) {
return null;
}

const ownProps = {
ref: combinedRef,
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
} satisfies DefaultProps<'div'>;

const defaultProps = { ...ownProps, ...getFloatingProps(), ...transitionProps };

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],
props: mergeProps<'div'>(defaultProps, otherProps),
});

if (!element) {
return null;
}

return (
<FloatingFocusManager
context={floatingContext}
modal={modal}
outsideElementsInert={modal}
>
{renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
})}
{element}
</FloatingFocusManager>
);
});
17 changes: 7 additions & 10 deletions packages/headless/src/primitives/dialog/dialog-trigger.tsx
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, type DefaultProps, mergeProps, renderElement } from '../../utils';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useDialogContext } from './dialog-context';

/** Props for {@link DialogTrigger}. */
Expand All @@ -15,24 +14,22 @@ export const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerPr
const { render, ...otherProps } = props;
const { open, refs, getReferenceProps } = useDialogContext();

// 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 ownProps = {
type: 'button',
ref: combinedRef,
} satisfies DefaultProps<'button'>;

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<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
Expand Down
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { cssVars } from './css-vars';
export { resetLayoutStyles } from './reset-layout-styles';
export { type ComponentProps, type DefaultProps, mergeProps, type RenderProp, renderElement } from './render-element';
export { useRender } from './use-render';
2 changes: 1 addition & 1 deletion packages/headless/src/utils/render-element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export type DefaultProps<Tag extends keyof React.JSX.IntrinsicElements> = React.
/**
* Maps state keys to functions that return data-attribute objects (or null).
*/
type StateAttributesMapping<S> = {
export type StateAttributesMapping<S> = {
[K in keyof S]?: (value: S[K]) => Record<string, string> | null;
};

Expand Down
189 changes: 189 additions & 0 deletions packages/headless/src/utils/use-render.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { cleanup, render, renderHook, screen } from '@testing-library/react';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { useRender } from './use-render';

afterEach(() => {
cleanup();
});

describe('useRender', () => {
it('renders the default tag when no render prop', () => {
function C() {
return useRender({ defaultTagName: 'span', props: { 'data-testid': 'test', children: 'hello' } });
}
render(<C />);

const el = screen.getByTestId('test');
expect(el.tagName).toBe('SPAN');
expect(el).toHaveTextContent('hello');
});

it('renders via a render function', () => {
function C() {
return useRender({
defaultTagName: 'div',
render: props => <article {...props} />,
props: { 'data-testid': 'test', children: 'content' },
});
}
render(<C />);

expect(screen.getByTestId('test').tagName).toBe('ARTICLE');
});

it('renders via a render element (clones it)', () => {
function C() {
return useRender({
defaultTagName: 'div',
render: <article data-variant='x' />,
props: { 'data-testid': 'test', children: 'content' },
});
}
render(<C />);

const el = screen.getByTestId('test');
expect(el.tagName).toBe('ARTICLE');
expect(el).toHaveAttribute('data-variant', 'x');
expect(el).toHaveTextContent('content');
});

it('returns null when enabled is false', () => {
const { result } = renderHook(() =>
useRender({ defaultTagName: 'div', enabled: false, props: { children: 'hidden' } }),
);
expect(result.current).toBeNull();
});

it('applies state attributes via stateAttributesMapping', () => {
function C() {
return useRender({
defaultTagName: 'button',
state: { open: true },
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: { 'data-testid': 'test' },
});
}
render(<C />);

const el = screen.getByTestId('test');
expect(el).toHaveAttribute('data-cl-open', '');
expect(el).not.toHaveAttribute('data-cl-closed');
});

it('merges a single ref onto the rendered element', () => {
const ref = React.createRef<HTMLDivElement>();
function C() {
return useRender({ defaultTagName: 'div', ref, props: { 'data-testid': 'test' } });
}
render(<C />);

expect(ref.current).toBe(screen.getByTestId('test'));
});

it('merges an array of refs onto the rendered element', () => {
const refA = React.createRef<HTMLDivElement>();
const refB = vi.fn();
function C() {
return useRender({ defaultTagName: 'div', ref: [refA, refB], props: { 'data-testid': 'test' } });
}
render(<C />);

const el = screen.getByTestId('test');
expect(refA.current).toBe(el);
expect(refB).toHaveBeenCalledWith(el);
});

it("merges a render element's own ref with the params ref", () => {
const paramRef = React.createRef<HTMLElement>();
const elementRef = React.createRef<HTMLElement>();
function C() {
return useRender({
defaultTagName: 'div',
ref: paramRef,
render: (
<article
ref={elementRef}
data-testid='test'
/>
),
});
}
render(<C />);

const el = screen.getByTestId('test');
expect(paramRef.current).toBe(el);
expect(elementRef.current).toBe(el);
});

it('concatenates className when cloning a render element', () => {
function C() {
return useRender({
defaultTagName: 'div',
render: (
<article
className='from-element'
data-testid='test'
/>
),
props: { className: 'from-part' },
});
}
render(<C />);

expect(screen.getByTestId('test')).toHaveClass('from-part', 'from-element');
});

it('chains event handlers when cloning a render element', () => {
const partHandler = vi.fn();
const elementHandler = vi.fn();
function C() {
return useRender({
defaultTagName: 'button',
render: (
<button
type='button'
onClick={elementHandler}
data-testid='test'
/>
),
props: { onClick: partHandler },
});
}
render(<C />);

screen.getByTestId('test').click();

expect(partHandler).toHaveBeenCalledTimes(1);
expect(elementHandler).toHaveBeenCalledTimes(1);
});

it('passes computed props plus a merged ref to the render function', () => {
const ref = React.createRef<HTMLDivElement>();
const renderFn = vi.fn((props: React.HTMLAttributes<HTMLDivElement>) => <div {...props} />);
function C() {
return useRender({
defaultTagName: 'div',
render: renderFn,
ref,
state: { open: true },
stateAttributesMapping: {
open: (v: boolean) => (v ? { 'data-cl-open': '' } : null),
},
props: { id: 'test' },
});
}
render(<C />);

expect(renderFn).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test',
'data-cl-open': '',
ref: expect.any(Function),
}),
);
});
});
Loading
Loading