@diskette/use-render 0.4.0 → 0.6.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,6 +1,6 @@
1
1
  # useRender
2
2
 
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.
3
+ A React hook for component libraries that lets consumers swap the rendered element—like rendering a `<a>` instead of a `<button>`—while keeping all your component's behavior intact. It handles the tricky parts: merging refs, combining props, and letting className and style respond to internal state.
4
4
 
5
5
  ## Installation
6
6
 
@@ -8,68 +8,58 @@ A React hook for component libraries that enables flexible render prop patterns,
8
8
  pnpm add @diskette/use-render
9
9
  ```
10
10
 
11
- ## Features
11
+ ## Quick Start
12
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:
13
+ Here's a simple button component that consumers can render as any element:
24
14
 
25
15
  ```tsx
26
16
  import { useRender, type ComponentProps } from '@diskette/use-render'
27
17
 
28
- interface ButtonState {
29
- isPressed: boolean
30
- isHovered: boolean
31
- }
32
-
33
- type ButtonProps = ComponentProps<'button', ButtonState>
18
+ type ButtonProps = ComponentProps<'button', { isPressed: boolean }>
34
19
 
35
20
  function Button(props: ButtonProps) {
36
21
  const [isPressed, setIsPressed] = useState(false)
37
- const [isHovered, setIsHovered] = useState(false)
38
-
39
- const state: ButtonState = { isPressed, isHovered }
40
22
 
41
- return useRender('button', state, {
42
- baseProps: {
43
- className: 'btn',
44
- onMouseDown: () => setIsPressed(true),
45
- onMouseUp: () => setIsPressed(false),
46
- onMouseEnter: () => setIsHovered(true),
47
- onMouseLeave: () => setIsHovered(false),
23
+ return useRender(
24
+ 'button',
25
+ { isPressed },
26
+ {
27
+ baseProps: {
28
+ className: 'btn',
29
+ onMouseDown: () => setIsPressed(true),
30
+ onMouseUp: () => setIsPressed(false),
31
+ },
32
+ props,
48
33
  },
49
- props,
50
- })
34
+ )
51
35
  }
52
36
  ```
53
37
 
54
- ### Consuming the Component
55
-
56
- #### Default Usage
38
+ Now consumers can use it normally, or swap the element entirely:
57
39
 
58
40
  ```tsx
41
+ // Renders a <button>
59
42
  <Button className="primary">Click me</Button>
43
+
44
+ // Renders an <a> with all the button's behavior
45
+ <Button render={<a href="/path" />}>Go somewhere</Button>
60
46
  ```
61
47
 
62
- #### Override with Element
48
+ ## Usage
49
+
50
+ ### Overriding the `Element`
51
+
52
+ Pass a JSX element to `render` and it will be cloned with your component's props:
63
53
 
64
54
  ```tsx
65
55
  <Button render={<a href="/path" />}>Link styled as button</Button>
66
56
  ```
67
57
 
68
- #### Override with Function
58
+ Or pass a function to get full control over rendering, with access to both props and state:
69
59
 
70
60
  ```tsx
71
61
  <Button
72
- render={(state, props) => (
62
+ render={(props, state) => (
73
63
  <a {...props} href="/path" data-pressed={state.isPressed} />
74
64
  )}
75
65
  >
@@ -77,9 +67,9 @@ function Button(props: ButtonProps) {
77
67
  </Button>
78
68
  ```
79
69
 
80
- ### State-Aware className
70
+ ### State-Aware `className`
81
71
 
82
- Pass a function to resolve className based on component state:
72
+ Pass a function to compute `className` based on component state. The second argument gives you access to the base `className` set by the component:
83
73
 
84
74
  ```tsx
85
75
  <Button
@@ -91,15 +81,15 @@ Pass a function to resolve className based on component state:
91
81
  </Button>
92
82
  ```
93
83
 
94
- Or pass a string to merge with the default className (uses `clsx`):
84
+ Or just pass a string. It will be merged with the default `className`:
95
85
 
96
86
  ```tsx
97
87
  <Button className="primary large">Click me</Button>
98
88
  ```
99
89
 
100
- ### State-Aware style
90
+ ### State-Aware style `(CSSProperties)`
101
91
 
102
- Pass a function to resolve styles based on component state:
92
+ Same pattern works for inline styles:
103
93
 
104
94
  ```tsx
105
95
  <Button
@@ -114,83 +104,53 @@ Pass a function to resolve styles based on component state:
114
104
 
115
105
  ### Children as Render Function
116
106
 
117
- Access state in children:
107
+ Access state by passing a function:
118
108
 
119
109
  ```tsx
120
110
  <Button>{(state) => (state.isPressed ? 'Pressing...' : 'Click me')}</Button>
121
111
  ```
122
112
 
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
- ```
113
+ ### `render` vs `children` as Function
134
114
 
135
- #### Parameters
115
+ You might wonder why both exist—most libraries pick one or the other. They serve different purposes:
136
116
 
137
- - `tag` - The default element type to render (e.g., `'button'`, `'div'`)
138
- - `options` - Configuration object
117
+ - **`render`** swaps the element itself. Use it when you need a `<a>` instead of a `<button>`, or want to integrate with a router's `<Link>`.
118
+ - **`children` as function** changes what's inside the element. Use it when the content should react to internal state.
139
119
 
140
- #### Options
120
+ They compose naturally—you can use both at once:
141
121
 
142
- ```ts
143
- interface UseRenderOptions<T extends ElementType, S> {
144
- baseProps?: React.ComponentProps<T>
145
- props?: ComponentProps<T, S>
146
- ref?: React.Ref<any> | (React.Ref<any> | undefined)[]
147
- }
122
+ ```tsx
123
+ <Button render={<a href="/path" />}>
124
+ {(state) => (state.isPressed ? 'Going...' : 'Go somewhere')}
125
+ </Button>
148
126
  ```
149
127
 
150
- | Option | Description |
151
- | ----------- | ------------------------------------------------------------------ |
152
- | `baseProps` | Base 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
- ```
128
+ ## API
168
129
 
169
- ### `ClassNameResolver<S>`
130
+ ### `useRender(tag, state, options)`
170
131
 
171
132
  ```ts
172
- type ClassNameResolver<S> =
173
- | ((state: S, baseClassName?: string) => string | undefined)
174
- | string
175
- | undefined
133
+ function useRender<T extends ElementType, S>(
134
+ tag: T,
135
+ state: S,
136
+ options: UseRenderOptions<T, S>,
137
+ ): ReactNode
176
138
  ```
177
139
 
178
- ### `StyleResolver<S>`
140
+ | Parameter | Description |
141
+ | ------------------- | ------------------------------------------------------------------ |
142
+ | `tag` | Default element type (e.g., `'button'`, `'div'`) |
143
+ | `state` | Component state passed to resolvers and render functions |
144
+ | `options.baseProps` | Base props applied to the element (your component's defaults) |
145
+ | `options.props` | Consumer-provided props (typically forwarded from component props) |
146
+ | `options.ref` | Ref(s) to merge with the consumer's ref |
179
147
 
180
- ```ts
181
- type StyleResolver<S> =
182
- | ((state: S, baseStyle?: CSSProperties) => CSSProperties | undefined)
183
- | CSSProperties
184
- | undefined
185
- ```
148
+ ### `ComponentProps<ElementType, State>`
186
149
 
187
- ### `Renderer<T, S>`
150
+ Use this type for your component's public props. It extends `React.ComponentProps<T>` and augments `className`, `style`, and `children` to be state-aware as well as provide the `render` prop:
188
151
 
189
152
  ```ts
190
- type Renderer<T extends ElementType, S> = (
191
- state: S,
192
- props: ComponentPropsWithRef<T>,
193
- ) => ReactNode
153
+ type ButtonProps = ComponentProps<'button', ButtonState>
194
154
  ```
195
155
 
196
156
  ## License
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { useRender } from './use-render.ts';
2
- export type { ComponentProps, UseRenderOptions } from './use-render.ts';
2
+ export type { UseRenderOptions } from './use-render.ts';
3
+ export type { BaseComponentProps, ClassName, ComponentProps, ComponentRenderer, DataAttributes, Style, } from './types.ts';
package/dist/types.d.ts CHANGED
@@ -1,8 +1,16 @@
1
- import type { ComponentPropsWithRef, CSSProperties, ElementType, ReactNode } from 'react';
2
- export type ClassNameResolver<State> = ((state: State, baseClassName?: string) => string | undefined) | string | undefined;
3
- export type StyleResolver<State> = ((state: State, baseStyle?: CSSProperties) => CSSProperties | undefined) | CSSProperties | undefined;
4
- export type Renderer<S> = (state: S, props: React.HTMLAttributes<any> & {
5
- ref?: React.Ref<any> | undefined;
6
- }) => ReactNode;
1
+ import type { ComponentPropsWithRef, CSSProperties, ElementType, HTMLAttributes, JSX, ReactNode, Ref } from 'react';
2
+ export type ClassName<State> = ((state: State, baseClassName?: string) => string | undefined) | string;
3
+ export type Style<State> = ((state: State, baseStyle?: CSSProperties) => CSSProperties | undefined) | CSSProperties;
4
+ export type ComponentRenderer<S> = (props: HTMLAttributes<any> & {
5
+ ref?: Ref<any> | undefined;
6
+ }, state: S) => ReactNode;
7
7
  export type DataAttributes = Record<`data-${string}`, string | number | boolean>;
8
8
  export type BaseComponentProps<T extends ElementType> = Omit<ComponentPropsWithRef<T>, 'children' | 'className' | 'style'>;
9
+ export type ComponentProps<T extends ElementType, S> = BaseComponentProps<T> & {
10
+ children?: ReactNode | {
11
+ bivarianceHack(state: S): ReactNode;
12
+ }['bivarianceHack'];
13
+ className?: ClassName<S>;
14
+ style?: Style<S>;
15
+ render?: ComponentRenderer<S> | JSX.Element;
16
+ };
@@ -1,11 +1,5 @@
1
- import type { ElementType, JSX, ReactNode } from 'react';
2
- import type { BaseComponentProps, ClassNameResolver, DataAttributes, Renderer, StyleResolver } from './types.ts';
3
- export type ComponentProps<T extends ElementType, S> = BaseComponentProps<T> & {
4
- children?: ((state: S) => ReactNode) | ReactNode;
5
- className?: ClassNameResolver<S>;
6
- style?: StyleResolver<S>;
7
- render?: Renderer<S> | JSX.Element;
8
- };
1
+ import type { ElementType, ReactNode } from 'react';
2
+ import type { ComponentProps, DataAttributes } from './types.ts';
9
3
  export interface UseRenderOptions<T extends ElementType, S> {
10
4
  baseProps?: React.ComponentProps<T> & DataAttributes;
11
5
  props?: ComponentProps<T, S> & DataAttributes;
@@ -31,4 +25,4 @@ export interface UseRenderOptions<T extends ElementType, S> {
31
25
  * <Button render={<a href="#" />} />
32
26
  * ```
33
27
  */
34
- export declare function useRender<T extends ElementType, S>(tag: T, state: S, options: UseRenderOptions<T, S>): ReactNode;
28
+ export declare function useRender<T extends ElementType, S>(tag: T, state: S, options?: UseRenderOptions<T, S>): ReactNode;
@@ -21,14 +21,14 @@ import { isFunction, isString, mergeProps, resolveClassName, resolveStyle, } fro
21
21
  * <Button render={<a href="#" />} />
22
22
  * ```
23
23
  */
24
- export function useRender(tag, state, options) {
24
+ export function useRender(tag, state, options = {}) {
25
25
  // Workarounds for getting the prop objects to be typed. But should still be ok as the properties we need is common to all elements
26
26
  const baseProps = options.baseProps ?? {};
27
27
  const props = (options.props ?? {});
28
28
  const { className: baseClassName, style: baseStyle, children: baseChildren, ...base } = baseProps;
29
29
  const { className, style, children, ref, render, ...rest } = props ?? {};
30
- const resolvedClassName = resolveClassName(baseClassName, className, state);
31
- const resolvedStyle = resolveStyle(baseStyle, style, state);
30
+ const resolvedClassName = resolveClassName(state, baseClassName, className);
31
+ const resolvedStyle = resolveStyle(state, baseStyle, style);
32
32
  const refs = Array.isArray(options.ref)
33
33
  ? [ref, ...options.ref]
34
34
  : [ref, options.ref];
@@ -49,12 +49,12 @@ export function useRender(tag, state, options) {
49
49
  if (isValidElement(render)) {
50
50
  return cloneElement(render, resolvedProps, resolvedChildren);
51
51
  }
52
- // For `<Component render={(state, props) => <a {...props} />)} />`
52
+ // For `<Component render={(props) => <a {...props} />)} />`
53
53
  if (isFunction(render)) {
54
- return render(state, {
54
+ return render({
55
55
  ...resolvedProps,
56
56
  children: resolvedChildren,
57
- });
57
+ }, state);
58
58
  }
59
59
  return createElement(tag, resolvedProps, resolvedChildren ?? baseChildren);
60
60
  }
@@ -1,6 +1,7 @@
1
1
  import React, { useState } from 'react';
2
2
  import { describe, expect, test, vi } from 'vitest';
3
3
  import { render } from 'vitest-browser-react';
4
+ import {} from "./types.js";
4
5
  import { useRender } from "./use-render.js";
5
6
  function createBtn(defaultProps) {
6
7
  return function Button(props) {
@@ -126,9 +127,9 @@ describe('useRender', () => {
126
127
  await expect.element(link).toHaveTextContent('Link');
127
128
  await expect.element(link).toHaveAttribute('href', '/path');
128
129
  });
129
- test('render function receives state and props', async () => {
130
+ test('render function receives props and state', async () => {
130
131
  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
+ const { getByRole } = await render(<Button className="custom" render={(props, state) => (<a {...props} href="/path" data-active={state.isActive} onClick={(e) => {
132
133
  e.preventDefault();
133
134
  props.onClick?.(e);
134
135
  }}/>)}>
@@ -153,7 +154,7 @@ describe('useRender', () => {
153
154
  });
154
155
  test('function render prop with children as function', async () => {
155
156
  const Button = createBtn();
156
- const { getByRole } = await render(<Button render={(state, props) => (<a {...props} href="/path" data-active={state.isActive} onClick={(e) => {
157
+ const { getByRole } = await render(<Button render={(props, state) => (<a {...props} href="/path" data-active={state.isActive} onClick={(e) => {
157
158
  e.preventDefault();
158
159
  props.onClick?.(e);
159
160
  }}/>)}>
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { CSSProperties } from 'react';
2
- import type { ClassNameResolver, StyleResolver } from './types.ts';
2
+ import type { ClassName, Style } from './types.ts';
3
3
  export declare const isString: (value: unknown) => value is string;
4
4
  export declare const isFunction: (value: unknown) => value is Function;
5
5
  export declare const isUndefined: (value: unknown) => value is undefined;
@@ -15,14 +15,14 @@ export declare function mergeProps<T extends Record<string, unknown>>(defaultPro
15
15
  * - Merges string values using clsx
16
16
  * - Function props receive the resolved default value
17
17
  */
18
- export declare function resolveClassName<State>(defaultClassName: ClassNameResolver<State>, propsClassName: ClassNameResolver<State>, state: State): string | undefined;
18
+ export declare function resolveClassName<State>(state: State, defaultClassName?: ClassName<State>, propsClassName?: ClassName<State>): string | undefined;
19
19
  /**
20
20
  * Resolves and merges style values from defaultProps and props.
21
21
  * - Resolves function values with the provided state
22
22
  * - Merges object values using object spread
23
23
  * - Function props receive the resolved default value
24
24
  */
25
- export declare function resolveStyle<State>(defaultStyle: StyleResolver<State>, propsStyle: StyleResolver<State>, state: State): CSSProperties | undefined;
25
+ export declare function resolveStyle<State>(state: State, defaultStyle?: Style<State>, propsStyle?: Style<State>): CSSProperties | undefined;
26
26
  type ClassValue = ClassValue[] | Record<string, any> | string | number | bigint | null | boolean | undefined;
27
27
  export declare function clsx(...inputs: ClassValue[]): string;
28
28
  export {};
package/dist/utils.js CHANGED
@@ -27,7 +27,7 @@ export function mergeProps(defaultProps, props) {
27
27
  * - Merges string values using clsx
28
28
  * - Function props receive the resolved default value
29
29
  */
30
- export function resolveClassName(defaultClassName, propsClassName, state) {
30
+ export function resolveClassName(state, defaultClassName, propsClassName) {
31
31
  const resolvedDefault = isFunction(defaultClassName)
32
32
  ? defaultClassName(state)
33
33
  : defaultClassName;
@@ -49,7 +49,7 @@ export function resolveClassName(defaultClassName, propsClassName, state) {
49
49
  * - Merges object values using object spread
50
50
  * - Function props receive the resolved default value
51
51
  */
52
- export function resolveStyle(defaultStyle, propsStyle, state) {
52
+ export function resolveStyle(state, defaultStyle, propsStyle) {
53
53
  const resolvedDefault = isFunction(defaultStyle)
54
54
  ? defaultStyle(state)
55
55
  : defaultStyle;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@diskette/use-render",
3
3
  "type": "module",
4
- "version": "0.4.0",
4
+ "version": "0.6.0",
5
5
  "exports": "./dist/index.js",
6
6
  "files": [
7
7
  "dist"