@diskette/use-render 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,198 @@
1
1
  # useRender
2
2
 
3
- _description_
3
+ A React hook for component libraries that enables flexible render prop patterns, allowing consumers to override a component's default rendered element while maintaining proper `ref`, `className`, and `style` merging.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @diskette/use-render
9
+ ```
10
+
11
+ ## Features
12
+
13
+ - **Render prop support** - Override elements via `render` prop (element or function)
14
+ - **State-aware className** - Resolve classNames dynamically based on component state
15
+ - **State-aware style** - Resolve styles dynamically based on component state
16
+ - **Automatic ref merging** - Combines multiple refs seamlessly
17
+ - **Children as render function** - Pass children as a function receiving state
18
+
19
+ ## Usage
20
+
21
+ ### Building a Component
22
+
23
+ Use `useRender` inside your component to enable the render prop pattern:
24
+
25
+ ```tsx
26
+ import { useRender, type ComponentProps } from '@diskette/use-render'
27
+
28
+ interface ButtonState {
29
+ isPressed: boolean
30
+ isHovered: boolean
31
+ }
32
+
33
+ type ButtonProps = ComponentProps<'button', ButtonState>
34
+
35
+ function Button(props: ButtonProps) {
36
+ const [isPressed, setIsPressed] = useState(false)
37
+ const [isHovered, setIsHovered] = useState(false)
38
+
39
+ const state: ButtonState = { isPressed, isHovered }
40
+
41
+ return useRender('button', state, {
42
+ defaultProps: {
43
+ className: 'btn',
44
+ onMouseDown: () => setIsPressed(true),
45
+ onMouseUp: () => setIsPressed(false),
46
+ onMouseEnter: () => setIsHovered(true),
47
+ onMouseLeave: () => setIsHovered(false),
48
+ },
49
+ props,
50
+ })
51
+ }
52
+ ```
53
+
54
+ ### Consuming the Component
55
+
56
+ #### Default Usage
57
+
58
+ ```tsx
59
+ <Button className="primary">Click me</Button>
60
+ ```
61
+
62
+ #### Override with Element
63
+
64
+ ```tsx
65
+ <Button render={<a href="/path" />}>Link styled as button</Button>
66
+ ```
67
+
68
+ #### Override with Function
69
+
70
+ ```tsx
71
+ <Button
72
+ render={(state, props) => (
73
+ <a {...props} href="/path" data-pressed={state.isPressed} />
74
+ )}
75
+ >
76
+ Link with state access
77
+ </Button>
78
+ ```
79
+
80
+ ### State-Aware className
81
+
82
+ Pass a function to resolve className based on component state:
83
+
84
+ ```tsx
85
+ <Button
86
+ className={(state, defaultClassName) =>
87
+ `${defaultClassName} ${state.isPressed ? 'pressed' : ''}`
88
+ }
89
+ >
90
+ Press me
91
+ </Button>
92
+ ```
93
+
94
+ Or pass a string to merge with the default className (uses `clsx`):
95
+
96
+ ```tsx
97
+ <Button className="primary large">Click me</Button>
98
+ ```
99
+
100
+ ### State-Aware style
101
+
102
+ Pass a function to resolve styles based on component state:
103
+
104
+ ```tsx
105
+ <Button
106
+ style={(state) => ({
107
+ backgroundColor: state.isPressed ? 'darkblue' : 'blue',
108
+ transform: state.isPressed ? 'scale(0.98)' : undefined,
109
+ })}
110
+ >
111
+ Press me
112
+ </Button>
113
+ ```
114
+
115
+ ### Children as Render Function
116
+
117
+ Access state in children:
118
+
119
+ ```tsx
120
+ <Button>{(state) => (state.isPressed ? 'Pressing...' : 'Click me')}</Button>
121
+ ```
122
+
123
+ ## API
124
+
125
+ ### `useRender(tag, options)`
126
+
127
+ ```ts
128
+ function useRender<T extends ElementType, S>(
129
+ tag: T,
130
+ state,
131
+ options: UseRenderOptions<T, S>,
132
+ ): ReactNode
133
+ ```
134
+
135
+ #### Parameters
136
+
137
+ - `tag` - The default element type to render (e.g., `'button'`, `'div'`)
138
+ - `options` - Configuration object
139
+
140
+ #### Options
141
+
142
+ ```ts
143
+ interface UseRenderOptions<T extends ElementType, S> {
144
+ defaultProps?: React.ComponentProps<T>
145
+ props?: ComponentProps<T, S>
146
+ ref?: React.Ref<any> | (React.Ref<any> | undefined)[]
147
+ }
148
+ ```
149
+
150
+ | Option | Description |
151
+ | -------------- | ------------------------------------------------------------------ |
152
+ | `defaultProps` | Default props applied to the element |
153
+ | `props` | Consumer-provided props (typically forwarded from component props) |
154
+ | `ref` | Ref(s) to merge with the consumer's ref |
155
+
156
+ ### `ComponentProps<T, S>`
157
+
158
+ Type for component's external public props. Components using `useRender` should use this type (or extend from it) instead of `React.ComponentProps`. It's essentially `React.ComponentProps<T>` augmented with state-aware `className`, `style`, `children`, and the `render` prop:
159
+
160
+ ```ts
161
+ type ComponentProps<T extends ElementType, S> = BaseComponentProps<T> & {
162
+ children?: ((state: S) => ReactNode) | ReactNode
163
+ className?: ClassNameResolver<S>
164
+ style?: StyleResolver<S>
165
+ render?: Renderer<T, S> | JSX.Element
166
+ }
167
+ ```
168
+
169
+ ### `ClassNameResolver<S>`
170
+
171
+ ```ts
172
+ type ClassNameResolver<S> =
173
+ | ((state: S, defaultClassName?: string) => string | undefined)
174
+ | string
175
+ | undefined
176
+ ```
177
+
178
+ ### `StyleResolver<S>`
179
+
180
+ ```ts
181
+ type StyleResolver<S> =
182
+ | ((state: S, defaultStyle?: CSSProperties) => CSSProperties | undefined)
183
+ | CSSProperties
184
+ | undefined
185
+ ```
186
+
187
+ ### `Renderer<T, S>`
188
+
189
+ ```ts
190
+ type Renderer<T extends ElementType, S> = (
191
+ state: S,
192
+ props: ComponentPropsWithRef<T>,
193
+ ) => ReactNode
194
+ ```
4
195
 
5
196
  ## License
6
197
 
7
- [MIT](./LICENSE) License ©
198
+ [MIT](./LICENSE) License
package/dist/types.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- import type { ComponentProps, CSSProperties, ElementType, ReactNode } from 'react';
2
- export type ClassNameResolver<State> = ((state: State, defaultClassName?: string | undefined) => string | undefined) | string | undefined;
3
- export type StyleResolver<State> = ((state: State, defaultStyle?: CSSProperties | undefined) => CSSProperties | undefined) | CSSProperties | undefined;
4
- export type Renderer<T extends ElementType, S> = (state: S, props: ComponentProps<T>) => ReactNode;
1
+ import type { ComponentPropsWithRef, CSSProperties, ElementType, ReactNode } from 'react';
2
+ export type ClassNameResolver<State> = ((state: State, defaultClassName?: string) => string | undefined) | string | undefined;
3
+ export type StyleResolver<State> = ((state: State, defaultStyle?: CSSProperties) => CSSProperties | undefined) | CSSProperties | undefined;
4
+ export type Renderer<S> = (state: S, props: React.HTMLAttributes<any> & {
5
+ ref?: React.Ref<any> | undefined;
6
+ }) => ReactNode;
5
7
  export type DataAttributes = Record<`data-${string}`, string | number | boolean>;
6
- export type BaseComponentProps<T extends ElementType> = Omit<ComponentProps<T>, 'children' | 'className' | 'style'>;
8
+ export type BaseComponentProps<T extends ElementType> = Omit<ComponentPropsWithRef<T>, 'children' | 'className' | 'style'>;
@@ -0,0 +1,14 @@
1
+ import { type Ref, type RefCallback } from 'react';
2
+ /**
3
+ * Assigns a value to a ref.
4
+ * @param ref The ref to assign the value to.
5
+ * @param value The value to assign to the ref.
6
+ * @returns The ref cleanup callback, if any.
7
+ */
8
+ export declare function assignRef<T>(ref: Ref<T> | undefined | null, value: T | null): ReturnType<RefCallback<T>>;
9
+ /**
10
+ * Composes multiple refs into a single one and memoizes the result to avoid refs execution on each render.
11
+ * @param refs List of refs to merge.
12
+ * @returns Merged ref.
13
+ */
14
+ export declare function useComposedRef<T>(refs: (Ref<T> | undefined)[]): Ref<T>;
@@ -0,0 +1,37 @@
1
+ import { useMemo } from 'react';
2
+ /**
3
+ * Assigns a value to a ref.
4
+ * @param ref The ref to assign the value to.
5
+ * @param value The value to assign to the ref.
6
+ * @returns The ref cleanup callback, if any.
7
+ */
8
+ export function assignRef(ref, value) {
9
+ if (typeof ref === 'function') {
10
+ return ref(value);
11
+ }
12
+ else if (ref) {
13
+ ref.current = value;
14
+ }
15
+ }
16
+ function mergeRefs(refs) {
17
+ return (value) => {
18
+ const cleanups = [];
19
+ for (const ref of refs) {
20
+ const cleanup = assignRef(ref, value);
21
+ const isCleanup = typeof cleanup === 'function';
22
+ cleanups.push(isCleanup ? cleanup : () => assignRef(ref, null));
23
+ }
24
+ return () => {
25
+ for (const cleanup of cleanups)
26
+ cleanup();
27
+ };
28
+ };
29
+ }
30
+ /**
31
+ * Composes multiple refs into a single one and memoizes the result to avoid refs execution on each render.
32
+ * @param refs List of refs to merge.
33
+ * @returns Merged ref.
34
+ */
35
+ export function useComposedRef(refs) {
36
+ return useMemo(() => mergeRefs(refs), refs);
37
+ }
@@ -4,16 +4,15 @@ export type ComponentProps<T extends ElementType, S> = BaseComponentProps<T> & {
4
4
  children?: ((state: S) => ReactNode) | ReactNode;
5
5
  className?: ClassNameResolver<S>;
6
6
  style?: StyleResolver<S>;
7
- render?: Renderer<T, S> | JSX.Element;
7
+ render?: Renderer<S> | JSX.Element;
8
8
  };
9
9
  export interface UseRenderOptions<T extends ElementType, S> {
10
10
  defaultProps?: React.ComponentProps<T> & DataAttributes;
11
11
  props?: ComponentProps<T, S> & DataAttributes;
12
12
  ref?: React.Ref<any> | (React.Ref<any> | undefined)[];
13
- state: S;
14
13
  }
15
14
  /**
16
15
  * Hook for enabling a render prop in custom components. Designed to be used by component libraries as an implementation detail
17
16
  * in providing a way to override a component's default rendered element.
18
17
  */
19
- export declare function useRender<T extends ElementType, S>(tag: T, options: UseRenderOptions<T, S>): JSX.Element;
18
+ export declare function useRender<T extends ElementType, S>(tag: T, state: S, options: UseRenderOptions<T, S>): ReactNode;
@@ -1,38 +1,39 @@
1
- import { cloneElement, createElement, isValidElement, useMemo } from 'react';
2
- import { useMergeRefs } from "./use-merge-refs.js";
3
- import { isFunction, isUndefined, resolveClassName, resolveStyle, } from "./utils.js";
1
+ import { cloneElement, createElement, isValidElement } from 'react';
2
+ import { useComposedRef } from "./use-composed-ref.js";
3
+ import { isFunction, isString, mergeProps, resolveClassName, resolveStyle, } from "./utils.js";
4
4
  /**
5
5
  * Hook for enabling a render prop in custom components. Designed to be used by component libraries as an implementation detail
6
6
  * in providing a way to override a component's default rendered element.
7
7
  */
8
- export function useRender(tag, options) {
9
- const { defaultProps, props = {}, ref: optionsRef, state } = options;
10
- const defaultClassName = defaultProps?.className;
11
- const defaultStyle = defaultProps?.style;
12
- const defaultChildren = defaultProps?.children;
13
- const { children, className: propsClassName, style: propsStyle, ref: propsRef, render, ...restProps } = defaultProps
14
- ? { ...defaultProps, ...props }
15
- : props;
16
- const resolvedClassName = resolveClassName(defaultClassName, propsClassName, state);
17
- const resolvedStyle = resolveStyle(defaultStyle, propsStyle, state);
18
- const refs = useMemo(() => {
19
- const refsArr = Array.isArray(optionsRef) ? optionsRef : [optionsRef];
20
- if (propsRef) {
21
- refsArr.push(propsRef);
22
- }
23
- return refsArr;
24
- }, [optionsRef, propsRef]);
25
- const mergedRef = useMergeRefs(refs);
8
+ export function useRender(tag, state, options) {
9
+ // Workarounds for getting the prop objects to be typed. But should still be ok as the properties we need is common to all elements
10
+ const defaultProps = options.defaultProps ?? {};
11
+ const props = (options.props ?? {});
12
+ const { className: defaultClassName, style: defaultStyle, children: defaultChildren, ...defaults } = defaultProps;
13
+ const { className, style, children, ref, render, ...rest } = props ?? {};
14
+ const resolvedClassName = resolveClassName(defaultClassName, className, state);
15
+ const resolvedStyle = resolveStyle(defaultStyle, style, state);
16
+ const refs = Array.isArray(options.ref)
17
+ ? [ref, ...options.ref]
18
+ : [ref, options.ref];
19
+ const mergedRef = useComposedRef(refs);
20
+ // Another workaround for getting component props typed
26
21
  const resolvedProps = {
27
- ...restProps,
28
- ...(!isUndefined(resolvedClassName) && { className: resolvedClassName }),
29
- ...(!isUndefined(resolvedStyle) && { style: resolvedStyle }),
30
- ...(mergedRef !== null && { ref: mergedRef }),
22
+ ...mergeProps(defaults, rest),
23
+ ref: mergedRef,
31
24
  };
25
+ if (isString(resolvedClassName)) {
26
+ resolvedProps.className = resolvedClassName;
27
+ }
28
+ if (typeof resolvedStyle === 'object') {
29
+ resolvedProps.style = resolvedStyle;
30
+ }
32
31
  const resolvedChildren = isFunction(children) ? children(state) : children;
32
+ // For `<Component render={<a />} />`
33
33
  if (isValidElement(render)) {
34
- return cloneElement(render, resolvedProps, resolvedChildren ?? defaultChildren);
34
+ return cloneElement(render, resolvedProps, resolvedChildren);
35
35
  }
36
+ // For `<Component render={(state, props) => <a {...props} />)} />`
36
37
  if (isFunction(render)) {
37
38
  return render(state, {
38
39
  ...resolvedProps,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,240 @@
1
+ import React, { useState } from 'react';
2
+ import { describe, expect, test, vi } from 'vitest';
3
+ import { render } from 'vitest-browser-react';
4
+ import { useRender } from "./use-render.js";
5
+ function createBtn(defaultProps) {
6
+ return function Button(props) {
7
+ const [isActive, setIsActive] = useState(false);
8
+ const state = { isActive };
9
+ return useRender('button', state, {
10
+ props,
11
+ defaultProps: {
12
+ onClick: () => setIsActive((cur) => !cur),
13
+ ...defaultProps,
14
+ },
15
+ });
16
+ };
17
+ }
18
+ describe('useRender', () => {
19
+ describe('className', () => {
20
+ test('renders with static string className', async () => {
21
+ const Button = createBtn();
22
+ const { getByRole } = await render(<Button className="btn-primary">Click</Button>);
23
+ const button = getByRole('button');
24
+ await expect.element(button).toHaveClass('btn-primary');
25
+ });
26
+ test('className function receives state', async () => {
27
+ const Button = createBtn();
28
+ const { getByRole } = await render(<Button className={(state) => (state.isActive ? 'active' : 'inactive')}>
29
+ Click
30
+ </Button>);
31
+ const button = getByRole('button');
32
+ await expect.element(button).toHaveClass('inactive');
33
+ await button.click();
34
+ await expect.element(button).toHaveClass('active');
35
+ });
36
+ test('className function receives defaultClassName as second argument', async () => {
37
+ const Button = createBtn({ className: 'btn-default' });
38
+ const { getByRole } = await render(<Button className={(_state, defaultClassName) => `custom ${defaultClassName ?? ''}`}>
39
+ Click
40
+ </Button>);
41
+ const button = getByRole('button');
42
+ await expect.element(button).toHaveClass('custom');
43
+ await expect.element(button).toHaveClass('btn-default');
44
+ });
45
+ test('merges props className with defaultProps className', async () => {
46
+ const Button = createBtn({ className: 'btn-base' });
47
+ const { getByRole } = await render(<Button className="btn-primary">Click</Button>);
48
+ const button = getByRole('button');
49
+ await expect.element(button).toHaveClass('btn-base');
50
+ await expect.element(button).toHaveClass('btn-primary');
51
+ });
52
+ });
53
+ describe('style', () => {
54
+ test('renders with static style object', async () => {
55
+ const Button = createBtn();
56
+ const { getByRole } = await render(<Button style={{ backgroundColor: 'red' }}>Click</Button>);
57
+ const button = getByRole('button');
58
+ await expect.element(button).toHaveStyle({ backgroundColor: 'red' });
59
+ });
60
+ test('style function receives state', async () => {
61
+ const Button = createBtn();
62
+ const { getByRole } = await render(<Button style={(state) => ({
63
+ backgroundColor: state.isActive ? 'green' : 'gray',
64
+ })}>
65
+ Click
66
+ </Button>);
67
+ const button = getByRole('button');
68
+ await expect.element(button).toHaveStyle({ backgroundColor: 'gray' });
69
+ await button.click();
70
+ await expect.element(button).toHaveStyle({ backgroundColor: 'green' });
71
+ });
72
+ test('style function receives defaultStyle as second argument', async () => {
73
+ const Button = createBtn({ style: { padding: '10px' } });
74
+ const { getByRole } = await render(<Button style={(_state, defaultStyle) => ({
75
+ ...defaultStyle,
76
+ color: 'blue',
77
+ })}>
78
+ Click
79
+ </Button>);
80
+ const button = getByRole('button');
81
+ await expect
82
+ .element(button)
83
+ .toHaveStyle({ padding: '10px', color: 'blue' });
84
+ });
85
+ test('merges props style with defaultProps style', async () => {
86
+ const Button = createBtn({
87
+ style: { padding: '10px', margin: '5px' },
88
+ });
89
+ const { getByRole } = await render(<Button style={{ padding: '20px', color: 'red' }}>Click</Button>);
90
+ const button = getByRole('button');
91
+ // Props style should override default where they overlap
92
+ await expect.element(button).toHaveStyle({
93
+ padding: '20px',
94
+ margin: '5px',
95
+ color: 'red',
96
+ });
97
+ });
98
+ });
99
+ describe('children', () => {
100
+ test('renders static children', async () => {
101
+ const Button = createBtn();
102
+ const { getByRole } = await render(<Button>Static Text</Button>);
103
+ const button = getByRole('button');
104
+ await expect.element(button).toHaveTextContent('Static Text');
105
+ });
106
+ test('children function receives state', async () => {
107
+ const Button = createBtn();
108
+ const { getByRole } = await render(<Button>{(state) => (state.isActive ? 'Active!' : 'Inactive')}</Button>);
109
+ const button = getByRole('button');
110
+ await expect.element(button).toHaveTextContent('Inactive');
111
+ await button.click();
112
+ await expect.element(button).toHaveTextContent('Active!');
113
+ });
114
+ test('uses defaultProps children when no children provided', async () => {
115
+ const Button = createBtn({ children: 'Default Text' });
116
+ const { getByRole } = await render(<Button />);
117
+ const button = getByRole('button');
118
+ await expect.element(button).toHaveTextContent('Default Text');
119
+ });
120
+ });
121
+ describe('render', () => {
122
+ test('renders element from render prop instead of default tag', async () => {
123
+ const Button = createBtn();
124
+ const { getByRole } = await render(<Button render={<a href="/path"/>}>Link</Button>);
125
+ const link = getByRole('link');
126
+ await expect.element(link).toHaveTextContent('Link');
127
+ await expect.element(link).toHaveAttribute('href', '/path');
128
+ });
129
+ test('render function receives state and props', async () => {
130
+ const Button = createBtn();
131
+ const { getByRole } = await render(<Button className="custom" render={(state, props) => (<a {...props} href="/path" data-active={state.isActive} onClick={(e) => {
132
+ e.preventDefault();
133
+ props.onClick?.(e);
134
+ }}/>)}>
135
+ Link
136
+ </Button>);
137
+ const link = getByRole('link');
138
+ await expect.element(link).toHaveTextContent('Link');
139
+ await expect.element(link).toHaveAttribute('href', '/path');
140
+ await expect.element(link).toHaveClass('custom');
141
+ await expect.element(link).toHaveAttribute('data-active', 'false');
142
+ await link.click();
143
+ await expect.element(link).toHaveAttribute('data-active', 'true');
144
+ });
145
+ test('element render prop with children as function', async () => {
146
+ const Button = createBtn();
147
+ const { getByRole } = await render(<Button render={<a href="/path"/>}>
148
+ {(state) => (state.isActive ? 'Active Link' : 'Inactive Link')}
149
+ </Button>);
150
+ const link = getByRole('link');
151
+ await expect.element(link).toHaveTextContent('Inactive Link');
152
+ await expect.element(link).toHaveAttribute('href', '/path');
153
+ });
154
+ test('function render prop with children as function', async () => {
155
+ const Button = createBtn();
156
+ const { getByRole } = await render(<Button render={(state, props) => (<a {...props} href="/path" data-active={state.isActive} onClick={(e) => {
157
+ e.preventDefault();
158
+ props.onClick?.(e);
159
+ }}/>)}>
160
+ {(state) => (state.isActive ? 'Active Link' : 'Inactive Link')}
161
+ </Button>);
162
+ const link = getByRole('link');
163
+ await expect.element(link).toHaveTextContent('Inactive Link');
164
+ await expect.element(link).toHaveAttribute('data-active', 'false');
165
+ await link.click();
166
+ await expect.element(link).toHaveTextContent('Active Link');
167
+ await expect.element(link).toHaveAttribute('data-active', 'true');
168
+ });
169
+ });
170
+ describe('event handler composition', () => {
171
+ test('composes event handlers - both run, user first then default', async () => {
172
+ const callOrder = [];
173
+ const defaultHandler = vi.fn(() => callOrder.push('default'));
174
+ const userHandler = vi.fn(() => callOrder.push('user'));
175
+ const Button = createBtn({ onMouseEnter: defaultHandler });
176
+ const { getByRole } = await render(<Button onMouseEnter={userHandler}>Click</Button>);
177
+ const button = getByRole('button');
178
+ await button.hover();
179
+ expect(defaultHandler).toHaveBeenCalledTimes(1);
180
+ expect(userHandler).toHaveBeenCalledTimes(1);
181
+ expect(callOrder).toEqual(['user', 'default']);
182
+ });
183
+ test('user handler return value is preserved', async () => {
184
+ const defaultHandler = vi.fn(() => 'default-result');
185
+ const userHandler = vi.fn(() => 'user-result');
186
+ let capturedResult;
187
+ function TestButton(props) {
188
+ const [isActive, setIsActive] = useState(false);
189
+ return useRender('button', { isActive }, {
190
+ props,
191
+ defaultProps: {
192
+ onClick: (_e) => {
193
+ setIsActive((cur) => !cur);
194
+ return defaultHandler();
195
+ },
196
+ },
197
+ });
198
+ }
199
+ const { getByRole } = await render(<TestButton onClick={() => {
200
+ capturedResult = userHandler();
201
+ return capturedResult;
202
+ }}>
203
+ Click
204
+ </TestButton>);
205
+ const button = getByRole('button');
206
+ await button.click();
207
+ expect(userHandler).toHaveBeenCalled();
208
+ expect(defaultHandler).toHaveBeenCalled();
209
+ expect(capturedResult).toBe('user-result');
210
+ });
211
+ test('only default handler runs when no user handler provided', async () => {
212
+ const defaultHandler = vi.fn();
213
+ const Button = createBtn({ onMouseEnter: defaultHandler });
214
+ const { getByRole } = await render(<Button>Click</Button>);
215
+ const button = getByRole('button');
216
+ await button.hover();
217
+ expect(defaultHandler).toHaveBeenCalledTimes(1);
218
+ });
219
+ test('only user handler runs when no default handler', async () => {
220
+ const userHandler = vi.fn();
221
+ const Button = createBtn();
222
+ const { getByRole } = await render(<Button onMouseEnter={userHandler}>Click</Button>);
223
+ const button = getByRole('button');
224
+ await button.hover();
225
+ expect(userHandler).toHaveBeenCalledTimes(1);
226
+ });
227
+ test('composed onClick still updates internal state', async () => {
228
+ const userHandler = vi.fn();
229
+ const Button = createBtn();
230
+ const { getByRole } = await render(<Button className={(state) => (state.isActive ? 'active' : 'inactive')} onClick={userHandler}>
231
+ Click
232
+ </Button>);
233
+ const button = getByRole('button');
234
+ await expect.element(button).toHaveClass('inactive');
235
+ await button.click();
236
+ await expect.element(button).toHaveClass('active');
237
+ expect(userHandler).toHaveBeenCalledTimes(1);
238
+ });
239
+ });
240
+ });
package/dist/utils.d.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  import type { CSSProperties } from 'react';
2
2
  import type { ClassNameResolver, StyleResolver } from './types.ts';
3
+ export declare const isString: (value: unknown) => value is string;
3
4
  export declare const isFunction: (value: unknown) => value is Function;
4
5
  export declare const isUndefined: (value: unknown) => value is undefined;
6
+ /**
7
+ * Merges two props objects, composing event handlers.
8
+ * - Event handlers (on[A-Z]) are composed: user handler runs first, then default
9
+ * - All other props: user props override defaults
10
+ */
11
+ export declare function mergeProps<T extends Record<string, unknown>>(defaultProps: T, props: T): T;
5
12
  /**
6
13
  * Resolves and merges className values from defaultProps and props.
7
14
  * - Resolves function values with the provided state
@@ -16,3 +23,6 @@ export declare function resolveClassName<State>(defaultClassName: ClassNameResol
16
23
  * - Function props receive the resolved default value
17
24
  */
18
25
  export declare function resolveStyle<State>(defaultStyle: StyleResolver<State>, propsStyle: StyleResolver<State>, state: State): CSSProperties | undefined;
26
+ type ClassValue = ClassValue[] | Record<string, any> | string | number | bigint | null | boolean | undefined;
27
+ export declare function clsx(...inputs: ClassValue[]): string;
28
+ export {};
package/dist/utils.js CHANGED
@@ -1,6 +1,26 @@
1
- import { clsx } from 'clsx';
1
+ export const isString = (value) => typeof value === 'string';
2
2
  export const isFunction = (value) => typeof value === 'function';
3
3
  export const isUndefined = (value) => typeof value === 'undefined';
4
+ /**
5
+ * Merges two props objects, composing event handlers.
6
+ * - Event handlers (on[A-Z]) are composed: user handler runs first, then default
7
+ * - All other props: user props override defaults
8
+ */
9
+ export function mergeProps(defaultProps, props) {
10
+ const result = { ...defaultProps, ...props };
11
+ for (const key in defaultProps) {
12
+ const isHandler = /^on[A-Z]/.test(key);
13
+ if (isHandler && defaultProps[key] && props[key]) {
14
+ ;
15
+ result[key] = (...args) => {
16
+ const userResult = props[key](...args);
17
+ defaultProps[key](...args);
18
+ return userResult;
19
+ };
20
+ }
21
+ }
22
+ return result;
23
+ }
4
24
  /**
5
25
  * Resolves and merges className values from defaultProps and props.
6
26
  * - Resolves function values with the provided state
@@ -47,3 +67,15 @@ export function resolveStyle(defaultStyle, propsStyle, state) {
47
67
  return resolvedDefault;
48
68
  }
49
69
  }
70
+ export function clsx(...inputs) {
71
+ let str = '';
72
+ let tmp;
73
+ for (let i = 0; i < inputs.length; i++) {
74
+ if ((tmp = arguments[i])) {
75
+ if (isString(tmp)) {
76
+ str += (str && ' ') + tmp;
77
+ }
78
+ }
79
+ }
80
+ return str;
81
+ }
package/package.json CHANGED
@@ -1,29 +1,36 @@
1
1
  {
2
2
  "name": "@diskette/use-render",
3
3
  "type": "module",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "exports": "./dist/index.js",
6
6
  "files": [
7
7
  "dist"
8
8
  ],
9
9
  "peerDependencies": {
10
- "@types/react": "*",
11
- "react": "^18.0 || ^19.0"
10
+ "@types/react": "^18.0.0 || ^19.0.0",
11
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
12
+ "react": "^18.0.0 || ^19.0.0",
13
+ "react-dom": "^18.0.0 || ^19.0.0"
12
14
  },
13
15
  "peerDependenciesMeta": {
14
16
  "@types/react": {
15
17
  "optional": true
18
+ },
19
+ "@types/react-dom": {
20
+ "optional": true
16
21
  }
17
22
  },
18
23
  "devDependencies": {
19
24
  "@changesets/cli": "^2.29.7",
20
25
  "@types/react": "^19.2.6",
26
+ "@types/react-dom": "^19.2.3",
21
27
  "@vitejs/plugin-react": "^5.1.1",
22
28
  "@vitest/browser-playwright": "^4.0.13",
23
29
  "oxlint": "^1.29.0",
24
30
  "oxlint-tsgolint": "^0.8.1",
25
31
  "prettier": "^3.6.2",
26
32
  "react": "^19.2.0",
33
+ "react-dom": "^19.2.0",
27
34
  "typescript": "^5.9.3",
28
35
  "vitest": "^4.0.13",
29
36
  "vitest-browser-react": "^2.0.2"
@@ -40,13 +47,9 @@
40
47
  "singleQuote": true
41
48
  },
42
49
  "license": "MIT",
43
- "dependencies": {
44
- "clsx": "^2.1.1"
45
- },
46
50
  "scripts": {
47
- "dev": "tsc --watch -p tsconfig.build.json",
48
- "build": "rm -rf && tsc -p tsconfig.build.json",
49
- "test": "vitest run",
51
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
52
+ "test": "vitest run --browser.headless",
50
53
  "typecheck": "tsc",
51
54
  "lint": "oxlint --type-aware src",
52
55
  "release": "changeset publish && git push --follow-tags"
@@ -1,8 +0,0 @@
1
- import * as React from 'react';
2
- type Ref<Instance> = Exclude<React.Ref<Instance>, React.RefObject<Instance>> | React.RefObject<Instance | null>;
3
- /**
4
- * Merges an array of refs into a single memoized callback ref or `null`.
5
- * @see https://floating-ui.com/docs/react-utils#usemergerefs
6
- */
7
- export declare function useMergeRefs<Instance>(refs: Array<Ref<Instance> | undefined>): null | React.RefCallback<Instance>;
8
- export {};
@@ -1,45 +0,0 @@
1
- import * as React from 'react';
2
- /**
3
- * Merges an array of refs into a single memoized callback ref or `null`.
4
- * @see https://floating-ui.com/docs/react-utils#usemergerefs
5
- */
6
- export function useMergeRefs(refs) {
7
- const cleanupRef = React.useRef(undefined);
8
- const refEffect = React.useCallback((instance) => {
9
- const cleanups = refs.map((ref) => {
10
- if (ref == null) {
11
- return;
12
- }
13
- if (typeof ref === 'function') {
14
- const refCallback = ref;
15
- const refCleanup = refCallback(instance);
16
- return typeof refCleanup === 'function'
17
- ? refCleanup
18
- : () => {
19
- refCallback(null);
20
- };
21
- }
22
- ref.current = instance;
23
- return () => {
24
- ref.current = null;
25
- };
26
- });
27
- return () => {
28
- cleanups.forEach((refCleanup) => refCleanup?.());
29
- };
30
- }, refs);
31
- return React.useMemo(() => {
32
- if (refs.every((ref) => ref == null)) {
33
- return null;
34
- }
35
- return (value) => {
36
- if (cleanupRef.current) {
37
- cleanupRef.current();
38
- cleanupRef.current = undefined;
39
- }
40
- if (value != null) {
41
- cleanupRef.current = refEffect(value);
42
- }
43
- };
44
- }, refs);
45
- }