@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 +193 -2
- package/dist/types.d.ts +7 -5
- package/dist/use-composed-ref.d.ts +14 -0
- package/dist/use-composed-ref.js +37 -0
- package/dist/use-render.d.ts +2 -3
- package/dist/use-render.js +27 -26
- package/dist/use-render.test.d.ts +1 -0
- package/dist/use-render.test.jsx +240 -0
- package/dist/utils.d.ts +10 -0
- package/dist/utils.js +33 -1
- package/package.json +12 -9
- package/dist/use-merge-refs.d.ts +0 -8
- package/dist/use-merge-refs.js +0 -45
package/README.md
CHANGED
|
@@ -1,7 +1,198 @@
|
|
|
1
1
|
# useRender
|
|
2
2
|
|
|
3
|
-
|
|
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 {
|
|
2
|
-
export type ClassNameResolver<State> = ((state: State, defaultClassName?: string
|
|
3
|
-
export type StyleResolver<State> = ((state: State, defaultStyle?: CSSProperties
|
|
4
|
-
export type Renderer<
|
|
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<
|
|
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
|
+
}
|
package/dist/use-render.d.ts
CHANGED
|
@@ -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<
|
|
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>):
|
|
18
|
+
export declare function useRender<T extends ElementType, S>(tag: T, state: S, options: UseRenderOptions<T, S>): ReactNode;
|
package/dist/use-render.js
CHANGED
|
@@ -1,38 +1,39 @@
|
|
|
1
|
-
import { cloneElement, createElement, isValidElement
|
|
2
|
-
import {
|
|
3
|
-
import { isFunction,
|
|
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
|
-
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const defaultChildren = defaultProps
|
|
13
|
-
const {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
...
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
48
|
-
"
|
|
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"
|
package/dist/use-merge-refs.d.ts
DELETED
|
@@ -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 {};
|
package/dist/use-merge-refs.js
DELETED
|
@@ -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
|
-
}
|